优化
This commit is contained in:
@@ -2,51 +2,10 @@
|
||||
* 可灵数字人 API
|
||||
*/
|
||||
import request from './http'
|
||||
import { MaterialService } from './material'
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
/**
|
||||
* 执行人脸识别并返回结果
|
||||
* 人脸识别
|
||||
*/
|
||||
async function performFaceIdentification(videoUrl) {
|
||||
const identifyRes = await identifyFace({ video_url: videoUrl })
|
||||
if (identifyRes.code !== 0) {
|
||||
throw new Error(identifyRes.msg || '识别失败')
|
||||
}
|
||||
|
||||
const faceData = identifyRes.data.data?.face_data?.[0]
|
||||
return {
|
||||
sessionId: identifyRes.data.sessionId,
|
||||
faceId: faceData?.face_id || null,
|
||||
startTime: faceData?.start_time || 0,
|
||||
endTime: faceData?.end_time || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建标准响应格式
|
||||
*/
|
||||
function buildIdentifyResponse(fileId, videoUrl, identifyData, isUploadedFile = false) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
fileId,
|
||||
videoUrl,
|
||||
sessionId: identifyData.sessionId,
|
||||
faceId: identifyData.faceId,
|
||||
startTime: isUploadedFile
|
||||
? Math.round(identifyData.startTime * 1000)
|
||||
: identifyData.startTime,
|
||||
endTime: isUploadedFile
|
||||
? Math.round(identifyData.endTime * 1000)
|
||||
: identifyData.endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== API 方法 ==========
|
||||
|
||||
export function identifyFace(data) {
|
||||
return request({
|
||||
url: '/webApi/api/tik/kling/identify-face',
|
||||
@@ -55,6 +14,9 @@ export function identifyFace(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建口型同步任务
|
||||
*/
|
||||
export function createLipSyncTask(data) {
|
||||
return request({
|
||||
url: '/webApi/api/tik/kling/task/create',
|
||||
@@ -63,43 +25,12 @@ export function createLipSyncTask(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取口型同步任务状态
|
||||
*/
|
||||
export function getLipSyncTask(taskId) {
|
||||
return request({
|
||||
url: `/webApi/api/tik/kling/lip-sync/${taskId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别已上传的视频
|
||||
*/
|
||||
export async function identifyUploadedVideo(videoFile) {
|
||||
const urlRes = await MaterialService.getVideoPlayUrl(videoFile.fileId)
|
||||
if (urlRes.code !== 0 || !urlRes.data) {
|
||||
throw new Error(urlRes.msg || '获取播放链接失败')
|
||||
}
|
||||
|
||||
const identifyData = await performFaceIdentification(urlRes.data)
|
||||
return buildIdentifyResponse(videoFile.id, urlRes.data, identifyData, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传视频并识别
|
||||
*/
|
||||
export async function uploadAndIdentifyVideo(file) {
|
||||
const uploadRes = await MaterialService.uploadFile(file, 'digital_human', null, null)
|
||||
if (uploadRes.code !== 0) {
|
||||
throw new Error(uploadRes.msg || '上传失败')
|
||||
}
|
||||
|
||||
const fileId = uploadRes.data
|
||||
|
||||
const urlRes = await MaterialService.getVideoPlayUrl(fileId)
|
||||
if (urlRes.code !== 0) {
|
||||
throw new Error(urlRes.msg || '获取播放链接失败')
|
||||
}
|
||||
|
||||
const identifyData = await performFaceIdentification(urlRes.data)
|
||||
return buildIdentifyResponse(fileId, urlRes.data, identifyData, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,38 +9,6 @@ import { API_BASE } from '@gold/config/api'
|
||||
// 使用 webApi 前缀,确保能够被代理
|
||||
const BASE_URL = `${API_BASE.APP_TIK}/file`
|
||||
|
||||
/**
|
||||
* 获取视频时长(秒)
|
||||
* @param {File} file - 视频文件对象
|
||||
* @returns {Promise<number>} 时长(秒)
|
||||
*/
|
||||
function getVideoDuration(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 只处理视频文件
|
||||
if (!file.type.startsWith('video/')) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true; // 静音,避免浏览器阻止自动播放
|
||||
|
||||
video.onloadedmetadata = function() {
|
||||
const duration = Math.round(video.duration);
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(duration);
|
||||
};
|
||||
|
||||
video.onerror = function() {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(60);
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材库 API 服务
|
||||
*/
|
||||
@@ -61,36 +29,6 @@ export const MaterialService = {
|
||||
return http.get(`${BASE_URL}/page`, { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {File} file - 文件对象
|
||||
* @param {string} fileCategory - 文件分类(video/generate/audio/mix/voice)
|
||||
* @param {number} duration - 视频时长(秒,可选,自动获取)
|
||||
* @param {number} groupId - 分组编号(可选)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async uploadFile(file, fileCategory, duration = null, groupId = null) {
|
||||
if (duration === null && file.type.startsWith('video/')) {
|
||||
duration = await getVideoDuration(file);
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('fileCategory', fileCategory)
|
||||
|
||||
if (duration !== null) {
|
||||
formData.append('duration', duration.toString());
|
||||
}
|
||||
|
||||
if (groupId !== null) {
|
||||
formData.append('groupId', groupId.toString())
|
||||
}
|
||||
|
||||
return http.post(`${BASE_URL}/upload`, formData, {
|
||||
timeout: 30 * 60 * 1000
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预签名URL(直传模式)
|
||||
* @param {Object} params - 请求参数
|
||||
|
||||
@@ -294,7 +294,7 @@ const getVideoPreviewUrl = (video) => {
|
||||
? video.coverBase64
|
||||
: `data:image/jpeg;base64,${video.coverBase64}`
|
||||
}
|
||||
return video.previewUrl || video.coverUrl || defaultCover
|
||||
return video.imgUrl || video.coverUrl || defaultCover
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
|
||||
@@ -5,50 +5,78 @@
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="voice-selector-with-preview">
|
||||
<a-select
|
||||
v-model:value="selectedVoiceId"
|
||||
placeholder="请选择音色"
|
||||
class="voice-select"
|
||||
:options="voiceOptions"
|
||||
@change="handleVoiceChange"
|
||||
style="width: calc(100% - 80px)"
|
||||
/>
|
||||
<div v-else class="voice-selector-wrapper">
|
||||
<!-- 选择器卡片 -->
|
||||
<div class="voice-card" :class="{ 'has-audio': audioUrl }">
|
||||
<div class="voice-card-header">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 14C13.1046 14 14 13.1046 14 12C14 10.8954 13.1046 10 12 10C10.8954 10 10 10.8954 10 12C10 13.1046 10.8954 14 12 14Z" fill="currentColor"/>
|
||||
<path d="M12 6C14.813 6 17.125 8.156 17.469 10.875C17.5 11.125 17.719 11.313 17.969 11.313H18.031C18.313 11.313 18.531 11.063 18.5 10.781C18.094 7.5 15.344 5 12 5C8.656 5 5.906 7.5 5.5 10.781C5.469 11.063 5.687 11.313 5.969 11.313H6.031C6.281 11.313 6.5 11.125 6.531 10.875C6.875 8.156 9.187 6 12 6Z" fill="currentColor" opacity="0.6"/>
|
||||
<path d="M12 3C16.5 3 20.188 6.5 20.469 11C20.5 11.25 20.719 11.438 20.969 11.438H21.031C21.313 11.438 21.531 11.188 21.5 10.906C21.156 5.875 17 2 12 2C7 2 2.844 5.875 2.5 10.906C2.469 11.188 2.687 11.438 2.969 11.438H3.031C3.281 11.438 3.5 11.25 3.531 11C3.813 6.5 7.5 3 12 3Z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="header-title">音色选择</span>
|
||||
<span v-if="currentVoiceName" class="header-badge">{{ currentVoiceName }}</span>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
class="preview-button"
|
||||
size="small"
|
||||
:disabled="!selectedVoiceId || isPlayerInitializing"
|
||||
:loading="previewLoadingVoiceId === selectedVoiceId"
|
||||
@click="handleSynthesize"
|
||||
>
|
||||
<template #icon>
|
||||
<SoundOutlined />
|
||||
</template>
|
||||
合成
|
||||
</a-button>
|
||||
<div class="voice-card-body">
|
||||
<div class="select-wrapper">
|
||||
<a-select
|
||||
v-model:value="selectedVoiceId"
|
||||
placeholder="请选择音色"
|
||||
class="voice-select"
|
||||
:options="voiceOptions"
|
||||
@change="handleVoiceChange"
|
||||
>
|
||||
<template #suffixIcon>
|
||||
<DownOutlined class="select-arrow" />
|
||||
</template>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
class="synthesize-btn"
|
||||
:class="{ 'btn-active': selectedVoiceId }"
|
||||
:disabled="!selectedVoiceId || isPlayerInitializing"
|
||||
:loading="previewLoadingVoiceId === selectedVoiceId"
|
||||
@click="handleSynthesize"
|
||||
>
|
||||
<template #icon>
|
||||
<SoundOutlined />
|
||||
</template>
|
||||
<span class="btn-text">合成试听</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放器区域 -->
|
||||
<transition name="slide-fade">
|
||||
<div v-if="audioUrl" class="player-section">
|
||||
<div ref="playerContainer" class="aplayer-container"></div>
|
||||
<div class="player-actions">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="downloadAudio"
|
||||
class="download-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
下载音频
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- APlayer 播放器容器 -->
|
||||
<div v-show="audioUrl" ref="playerContainer" class="aplayer-container"></div>
|
||||
|
||||
<!-- 下载按钮 -->
|
||||
<a-button
|
||||
v-show="audioUrl"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="downloadAudio"
|
||||
class="download-link"
|
||||
>
|
||||
下载音频
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { Empty, message } from 'ant-design-vue'
|
||||
import { SoundOutlined } from '@ant-design/icons-vue'
|
||||
import { SoundOutlined, DownloadOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
|
||||
import APlayer from 'aplayer'
|
||||
@@ -294,46 +322,230 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.voice-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-voices {
|
||||
padding: 16px 0;
|
||||
padding: 24px 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 音色选择器和试听按钮的容器 */
|
||||
.voice-selector-with-preview {
|
||||
/* 主容器 */
|
||||
.voice-selector-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 音色卡片 */
|
||||
.voice-card {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&.has-audio {
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.voice-card-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 下拉框样式 */
|
||||
.voice-select {
|
||||
flex: 1;
|
||||
.header-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 试听按钮样式 */
|
||||
.preview-button {
|
||||
height: 32px;
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-badge {
|
||||
margin-left: auto;
|
||||
padding: 2px 10px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APlayer 容器样式 */
|
||||
.aplayer-container {
|
||||
margin-top: 12px;
|
||||
/* 卡片主体 */
|
||||
.voice-card-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 下载链接样式 */
|
||||
.download-link {
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
.select-wrapper {
|
||||
flex: 1;
|
||||
|
||||
:deep(.ant-select) {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
|
||||
.ant-select-selector {
|
||||
height: 40px !important;
|
||||
border-radius: 10px !important;
|
||||
border-color: rgba(59, 130, 246, 0.2) !important;
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
line-height: 38px !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
line-height: 38px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
color: #3b82f6;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 合成按钮 */
|
||||
.synthesize-btn {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放器区域 */
|
||||
.player-section {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.aplayer-container {
|
||||
:deep(.aplayer) {
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
.aplayer-body {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
color: #3b82f6;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -157,7 +157,7 @@ const handleConfirm = async () => {
|
||||
|
||||
// 提取视频URL
|
||||
const videoUrls = videoGroupFiles.value
|
||||
.map(file => file?.fileUrl || file?.previewUrl)
|
||||
.map(file => file?.fileUrl || file?.imgUrl)
|
||||
.filter(Boolean)
|
||||
|
||||
if (videoUrls.length === 0) {
|
||||
|
||||
@@ -198,7 +198,7 @@ export function useUpload() {
|
||||
state.status = 'success'
|
||||
state.progress = 100
|
||||
|
||||
const fileId = completeData.data?.infraFileId || completeData.data?.userFileId
|
||||
const fileId = completeData.data?.userFileId
|
||||
const fileUrl = presignedData.data.presignedUrl
|
||||
onSuccess?.(fileId, fileUrl)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div class="generate-step">
|
||||
<div class="step-header">
|
||||
<div class="step-indicator">3</div>
|
||||
<h3 class="step-title">生成数字人视频</h3>
|
||||
</div>
|
||||
|
||||
<!-- 生成摘要 -->
|
||||
<div class="generate-summary">
|
||||
<div class="summary-item">
|
||||
<VideoCameraOutlined class="summary-icon" />
|
||||
<div class="summary-content">
|
||||
<span class="summary-label">视频素材</span>
|
||||
<span class="summary-value">{{ videoName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-item">
|
||||
<SoundOutlined class="summary-icon" />
|
||||
<div class="summary-content">
|
||||
<span class="summary-label">配音音色</span>
|
||||
<span class="summary-value">{{ store.voice?.name || '未选择' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-item">
|
||||
<FileTextOutlined class="summary-icon" />
|
||||
<div class="summary-content">
|
||||
<span class="summary-label">文案字数</span>
|
||||
<span class="summary-value">{{ store.text?.length || 0 }} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-item">
|
||||
<ClockCircleOutlined class="summary-icon" />
|
||||
<div class="summary-content">
|
||||
<span class="summary-label">人脸时长</span>
|
||||
<span class="summary-value">{{ formatDurationMs(store.faceDurationMs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-item">
|
||||
<AudioOutlined class="summary-icon" />
|
||||
<div class="summary-content">
|
||||
<span class="summary-label">音频时长</span>
|
||||
<span class="summary-value">{{ formatDurationMs(store.audioDurationMs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴最终对比 -->
|
||||
<TimelinePanel
|
||||
v-if="store.timeline"
|
||||
:face-duration-ms="store.timeline.videoDurationMs"
|
||||
:audio-duration-ms="store.timeline.audioDurationMs"
|
||||
:face-start-time="store.timeline.faceStartTime"
|
||||
:face-end-time="store.timeline.faceEndTime"
|
||||
/>
|
||||
|
||||
<!-- 积分预估 -->
|
||||
<div class="points-section">
|
||||
<div class="points-row">
|
||||
<span class="points-label">预计消耗积分</span>
|
||||
<span class="points-value">{{ estimatedPoints }} 积分</span>
|
||||
</div>
|
||||
<div class="points-row">
|
||||
<span class="points-label">当前余额</span>
|
||||
<span class="points-value">{{ userStore.remainingPoints.toLocaleString() }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="action-section">
|
||||
<a-button
|
||||
v-if="!store.isDone"
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!store.canGenerate"
|
||||
:loading="store.createStep === 'creating'"
|
||||
block
|
||||
@click="store.createTask"
|
||||
class="action-btn"
|
||||
>
|
||||
<template v-if="store.createStep === 'creating'">
|
||||
正在创建任务...
|
||||
</template>
|
||||
<template v-else>
|
||||
<PlayCircleOutlined /> 生成数字人视频
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 成功状态 -->
|
||||
<div v-if="store.isDone" class="success-result">
|
||||
<CheckCircleFilled class="success-icon" />
|
||||
<h4>任务已提交成功</h4>
|
||||
<p>请在任务中心查看生成进度</p>
|
||||
<a-button type="primary" @click="store.reset">
|
||||
重新生成
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="store.createStep === 'error'" class="error-result">
|
||||
<ExclamationCircleFilled class="error-icon" />
|
||||
<span>{{ store.error }}</span>
|
||||
<a-button type="link" @click="store.retry">重试</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航按钮 -->
|
||||
<div v-if="!store.isDone" class="nav-buttons">
|
||||
<a-button size="large" @click="store.goPrevPhase">
|
||||
<LeftOutlined /> 上一步
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
AudioOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleFilled,
|
||||
ExclamationCircleFilled,
|
||||
LeftOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import TimelinePanel from './TimelinePanel.vue'
|
||||
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
import { formatDurationMs } from '../utils/format'
|
||||
|
||||
const store = useDigitalHumanStore()
|
||||
const userStore = useUserStore()
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
|
||||
const videoName = computed(() => {
|
||||
return store.selectedVideo?.fileName || store.videoFile?.name || '未选择'
|
||||
})
|
||||
|
||||
const estimatedPoints = computed(() => {
|
||||
const points = pointsConfigStore.getConsumePoints('kling')
|
||||
return points ?? 150
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.generate-step {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: #fff;
|
||||
border-radius: 8px 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.generate-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
font-size: 18px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.points-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.points-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
border: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.success-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
background: #f0fdf4;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
|
||||
.success-icon {
|
||||
font-size: 48px;
|
||||
color: #22c55e;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #15803d;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.ant-btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.generate-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="step-navigation">
|
||||
<div
|
||||
v-for="(s, idx) in steps"
|
||||
:key="s.key"
|
||||
class="step-item"
|
||||
:class="{
|
||||
active: currentPhase === s.key,
|
||||
done: isStepDone(s.key),
|
||||
clickable: canNavigateTo(s.key)
|
||||
}"
|
||||
@click="handleNavigate(s.key)"
|
||||
>
|
||||
<div class="step-number">
|
||||
<CheckOutlined v-if="isStepDone(s.key)" />
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span class="step-label">{{ s.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckOutlined } from '@ant-design/icons-vue'
|
||||
import type { PipelinePhase } from '../types/identify-face'
|
||||
|
||||
interface Props {
|
||||
currentPhase: PipelinePhase
|
||||
videoReady: boolean
|
||||
audioReady: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'navigate', phase: PipelinePhase): void
|
||||
}>()
|
||||
|
||||
const steps = [
|
||||
{ key: 'select-video' as const, label: '选择视频' },
|
||||
{ key: 'add-voice' as const, label: '添加配音' },
|
||||
{ key: 'generate' as const, label: '生成视频' },
|
||||
]
|
||||
|
||||
function isStepDone(key: PipelinePhase): boolean {
|
||||
if (key === 'select-video') return props.videoReady
|
||||
if (key === 'add-voice') return props.audioReady
|
||||
return false
|
||||
}
|
||||
|
||||
function canNavigateTo(key: PipelinePhase): boolean {
|
||||
// 可以点击返回已完成的步骤
|
||||
if (key === 'select-video') return true
|
||||
if (key === 'add-voice') return props.videoReady
|
||||
if (key === 'generate') return props.audioReady
|
||||
return false
|
||||
}
|
||||
|
||||
function handleNavigate(key: PipelinePhase) {
|
||||
if (canNavigateTo(key)) {
|
||||
emit('navigate', key)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.step-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&:not(:last-child).done::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.step-number {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.done {
|
||||
.step-number {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.step-navigation {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 8px 12px;
|
||||
|
||||
&::after {
|
||||
width: 20px;
|
||||
right: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="timeline-panel">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-title">时间轴对比</span>
|
||||
<span v-if="showDurations" class="duration-info">
|
||||
人脸: {{ formatDuration(faceDurationMs) }}
|
||||
<template v-if="audioDurationMs > 0">
|
||||
| 音频: {{ formatDuration(audioDurationMs) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 刻度尺 -->
|
||||
<div class="timeline-ruler">
|
||||
<div
|
||||
v-for="mark in rulerMarks"
|
||||
:key="mark.time"
|
||||
class="ruler-mark"
|
||||
:style="{ left: mark.position + '%' }"
|
||||
>
|
||||
<span class="ruler-label">{{ mark.label }}</span>
|
||||
<span class="ruler-tick"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 轨道区域 -->
|
||||
<div class="timeline-tracks">
|
||||
<!-- 视频轨道 -->
|
||||
<div class="track video-track">
|
||||
<span class="track-icon">📹</span>
|
||||
<span class="track-label">视频</span>
|
||||
<div class="track-bar">
|
||||
<div
|
||||
class="track-fill video-fill"
|
||||
:style="{ width: videoBarWidth + '%' }"
|
||||
>
|
||||
<span class="track-time">{{ formatDuration(faceDurationMs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频轨道 -->
|
||||
<div class="track audio-track">
|
||||
<span class="track-icon">🎵</span>
|
||||
<span class="track-label">音频</span>
|
||||
<div class="track-bar">
|
||||
<div
|
||||
v-if="audioDurationMs > 0"
|
||||
class="track-fill audio-fill"
|
||||
:class="{ 'audio-exceed': isExceed }"
|
||||
:style="{ width: audioBarWidth + '%' }"
|
||||
>
|
||||
<span class="track-time">{{ formatDuration(audioDurationMs) }}</span>
|
||||
</div>
|
||||
<span v-else class="track-placeholder">等待生成音频</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时长差异提示 -->
|
||||
<div v-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
|
||||
<template v-if="diffStatus === 'match'">
|
||||
<CheckCircleOutlined class="diff-icon" />
|
||||
<span>时长匹配良好</span>
|
||||
</template>
|
||||
<template v-else-if="diffStatus === 'exceed'">
|
||||
<ExclamationCircleOutlined class="diff-icon" />
|
||||
<span>音频超出 {{ formatDuration(diffMs) }},请缩短文案</span>
|
||||
</template>
|
||||
<template v-else-if="diffStatus === 'short'">
|
||||
<InfoCircleOutlined class="diff-icon" />
|
||||
<span>音频较短,可适当增加文案</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { formatDurationMs } from '../utils/format'
|
||||
|
||||
interface Props {
|
||||
faceDurationMs: number
|
||||
audioDurationMs: number
|
||||
faceStartTime?: number
|
||||
faceEndTime?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
faceStartTime: 0,
|
||||
faceEndTime: 0,
|
||||
})
|
||||
|
||||
const maxDuration = computed(() => {
|
||||
const durations = [props.faceDurationMs, props.audioDurationMs].filter(d => d > 0)
|
||||
return Math.max(...durations, 1000)
|
||||
})
|
||||
|
||||
const videoBarWidth = computed(() =>
|
||||
Math.min(100, (props.faceDurationMs / maxDuration.value) * 100)
|
||||
)
|
||||
|
||||
const audioBarWidth = computed(() =>
|
||||
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
|
||||
)
|
||||
|
||||
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
|
||||
|
||||
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
|
||||
|
||||
const diffStatus = computed(() => {
|
||||
if (props.audioDurationMs === 0) return 'none'
|
||||
if (props.audioDurationMs > props.faceDurationMs) return 'exceed'
|
||||
if (props.audioDurationMs < props.faceDurationMs * 0.5) return 'short'
|
||||
return 'match'
|
||||
})
|
||||
|
||||
const showDurations = computed(() => props.faceDurationMs > 0)
|
||||
|
||||
const rulerMarks = computed(() => {
|
||||
if (maxDuration.value <= 0) return []
|
||||
|
||||
const marks: Array<{ time: number; label: string; position: number }> = []
|
||||
const interval = calculateInterval(maxDuration.value)
|
||||
const count = Math.ceil(maxDuration.value / interval)
|
||||
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const time = i * interval
|
||||
const position = (time / maxDuration.value) * 100
|
||||
if (position <= 100) {
|
||||
marks.push({
|
||||
time,
|
||||
label: `${(time / 1000).toFixed(0)}s`,
|
||||
position,
|
||||
})
|
||||
}
|
||||
}
|
||||
return marks
|
||||
})
|
||||
|
||||
function calculateInterval(duration: number): number {
|
||||
const seconds = duration / 1000
|
||||
if (seconds <= 10) return 2000
|
||||
if (seconds <= 30) return 5000
|
||||
if (seconds <= 60) return 10000
|
||||
return 15000
|
||||
}
|
||||
|
||||
const formatDuration = formatDurationMs
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.timeline-panel {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.timeline-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.duration-info {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
// 刻度尺
|
||||
.timeline-ruler {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 70px; // 为图标和标签留空间
|
||||
}
|
||||
|
||||
.ruler-mark {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ruler-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ruler-tick {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
// 轨道区域
|
||||
.timeline-tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.track-icon {
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-label {
|
||||
width: 34px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-bar {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.track-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.track-time {
|
||||
font-size: 11px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.track-placeholder {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-fill {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.audio-fill {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
|
||||
&.audio-exceed {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
// 差异提示
|
||||
.timeline-diff {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
.diff-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.match {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.exceed {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.short {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="video-select-step">
|
||||
<div class="step-header">
|
||||
<div class="step-indicator">1</div>
|
||||
<h3 class="step-title">选择视频素材</h3>
|
||||
</div>
|
||||
|
||||
<!-- 视频来源选项 -->
|
||||
<div class="video-options">
|
||||
<div
|
||||
class="video-option-card"
|
||||
:class="{ selected: store.videoSource === 'upload' }"
|
||||
@click="store.selectUploadMode"
|
||||
>
|
||||
<div class="option-icon">
|
||||
<CloudUploadOutlined />
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h4>上传新视频</h4>
|
||||
<p>支持 MP4、MOV 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="video-option-card"
|
||||
:class="{ selected: store.videoSource === 'select' }"
|
||||
@click="store.selectLibraryMode"
|
||||
>
|
||||
<div class="option-icon">
|
||||
<FolderOutlined />
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h4>从素材库选择</h4>
|
||||
<p>选择已上传的视频</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div
|
||||
v-if="store.videoSource === 'upload'"
|
||||
class="upload-zone"
|
||||
:class="{ dragover: dragOver }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".mp4,.mov"
|
||||
class="file-input"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div v-if="!store.videoPreviewUrl" class="upload-placeholder" @click="triggerFileSelect">
|
||||
<CloudUploadOutlined class="upload-icon" />
|
||||
<span class="upload-text">点击上传新视频</span>
|
||||
<span class="upload-hint">支持 MP4、MOV 格式(需 >3秒)</span>
|
||||
<button class="select-file-btn" @click.stop="triggerFileSelect">选择文件</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="upload-preview">
|
||||
<video
|
||||
:src="store.videoPreviewUrl"
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="preview-video"
|
||||
></video>
|
||||
<div class="preview-actions">
|
||||
<span class="preview-filename">{{ store.videoFile?.name }}</span>
|
||||
<button class="change-btn" @click="clearVideo">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选视频预览(素材库) -->
|
||||
<div v-if="store.selectedVideo && store.videoSource === 'select'" class="selected-preview">
|
||||
<div class="preview-thumb">
|
||||
<img
|
||||
:src="getVideoPreviewUrl(store.selectedVideo)"
|
||||
:alt="store.selectedVideo.fileName"
|
||||
/>
|
||||
</div>
|
||||
<div class="preview-info">
|
||||
<div class="preview-name">{{ store.selectedVideo.fileName }}</div>
|
||||
<div class="preview-meta">
|
||||
<span>{{ formatDuration(store.selectedVideo.duration) }}</span>
|
||||
<span class="divider">|</span>
|
||||
<span>{{ formatFileSize(store.selectedVideo.fileSize) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="change-btn" @click="clearVideo">更换</button>
|
||||
</div>
|
||||
|
||||
<!-- 识别状态 -->
|
||||
<div v-if="store.videoStep !== 'idle'" class="recognize-status" :class="store.videoStep">
|
||||
<div v-if="store.videoStep === 'uploading'" class="status-content loading">
|
||||
<a-spin size="small" />
|
||||
<span>正在上传视频...</span>
|
||||
</div>
|
||||
<div v-else-if="store.videoStep === 'recognizing'" class="status-content loading">
|
||||
<a-spin size="small" />
|
||||
<span>正在识别人脸...</span>
|
||||
</div>
|
||||
<div v-else-if="store.videoStep === 'recognized'" class="status-content success">
|
||||
<CheckCircleOutlined />
|
||||
<span>识别成功!人脸时长: {{ formatDurationMs(store.faceDurationMs) }}</span>
|
||||
</div>
|
||||
<div v-else-if="store.videoStep === 'error'" class="status-content error">
|
||||
<ExclamationCircleOutlined />
|
||||
<span>{{ store.error }}</span>
|
||||
<a-button size="small" type="link" @click="store.retry">重试</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下一步按钮 -->
|
||||
<div v-if="store.isVideoReady" class="step-actions">
|
||||
<a-button type="primary" size="large" block @click="store.goNextPhase">
|
||||
下一步:添加配音
|
||||
<RightOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
FolderOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
|
||||
import { formatDuration, formatDurationMs, formatFileSize } from '../utils/format'
|
||||
|
||||
const store = useDigitalHumanStore()
|
||||
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerFileSelect() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
store.handleFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
dragOver.value = false
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) {
|
||||
store.handleFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
function clearVideo() {
|
||||
if (store.videoPreviewUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(store.videoPreviewUrl)
|
||||
}
|
||||
store.videoFile = null
|
||||
store.selectedVideo = null
|
||||
store.videoPreviewUrl = ''
|
||||
store.videoSource = null
|
||||
store.resetProcess()
|
||||
}
|
||||
|
||||
function getVideoPreviewUrl(video: any): string {
|
||||
if (video.coverBase64) {
|
||||
return video.coverBase64.startsWith('data:')
|
||||
? video.coverBase64
|
||||
: `data:image/jpeg;base64,${video.coverBase64}`
|
||||
}
|
||||
return video.imgUrl || video.coverUrl || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.video-select-step {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
border-radius: 8px 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.video-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.video-option-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-style: solid;
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
font-size: 20px;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
min-height: 160px;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.dragover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 32px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.select-file-btn {
|
||||
margin-top: 8px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-preview {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.preview-filename {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.selected-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-thumb {
|
||||
width: 80px;
|
||||
height: 45px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e293b;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
|
||||
.divider {
|
||||
margin: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.change-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.recognize-status {
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
|
||||
&.recognized {
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&.loading {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="voice-config-step">
|
||||
<div class="step-header">
|
||||
<div class="step-indicator">2</div>
|
||||
<h3 class="step-title">添加配音</h3>
|
||||
</div>
|
||||
|
||||
<!-- 文案输入 -->
|
||||
<div class="text-section">
|
||||
<label class="section-label">播报文案</label>
|
||||
<a-textarea
|
||||
v-model:value="store.text"
|
||||
:placeholder="placeholder"
|
||||
:rows="4"
|
||||
:maxlength="4000"
|
||||
:show-count="true"
|
||||
class="text-input"
|
||||
:bordered="false"
|
||||
/>
|
||||
<div class="text-meta">
|
||||
<span>当前字数:{{ store.text?.length || 0 }} 字</span>
|
||||
<span>建议字数:{{ suggestedChars }} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音色选择 -->
|
||||
<div class="voice-section">
|
||||
<label class="section-label">选择音色</label>
|
||||
<VoiceSelector
|
||||
:synth-text="store.text"
|
||||
:speech-rate="store.speechRate"
|
||||
@select="store.setVoice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 语速调节 -->
|
||||
<div class="rate-section">
|
||||
<label class="section-label">语速调节</label>
|
||||
<div class="rate-control">
|
||||
<a-slider
|
||||
v-model:value="store.speechRate"
|
||||
:min="0.5"
|
||||
:max="2.0"
|
||||
:step="0.1"
|
||||
:marks="rateMarks"
|
||||
class="rate-slider"
|
||||
/>
|
||||
<span class="rate-value">{{ store.speechRate.toFixed(1) }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴对比 -->
|
||||
<TimelinePanel
|
||||
v-if="store.timeline"
|
||||
:face-duration-ms="store.timeline.videoDurationMs"
|
||||
:audio-duration-ms="store.timeline.audioDurationMs"
|
||||
:face-start-time="store.timeline.faceStartTime"
|
||||
:face-end-time="store.timeline.faceEndTime"
|
||||
/>
|
||||
|
||||
<!-- 生成音频按钮 -->
|
||||
<div class="action-section">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="store.audioStep === 'generating'"
|
||||
:disabled="!canGenerateAudio"
|
||||
block
|
||||
@click="store.generateAudio"
|
||||
class="action-btn"
|
||||
>
|
||||
<template v-if="store.audioStep === 'generated'">
|
||||
<ReloadOutlined /> 重新生成音频
|
||||
</template>
|
||||
<template v-else>
|
||||
<SoundOutlined /> 生成音频
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<div v-if="store.audioStep === 'generated'" class="audio-result">
|
||||
<CheckCircleOutlined class="success-icon" />
|
||||
<span>音频已生成,时长: {{ formatDurationMs(store.audioDurationMs) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="store.audioStep === 'error'" class="audio-error">
|
||||
<ExclamationCircleOutlined class="error-icon" />
|
||||
<span>{{ store.error }}</span>
|
||||
<a-button size="small" type="link" @click="store.retry">重试</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航按钮 -->
|
||||
<div v-if="store.isAudioReady" class="nav-buttons">
|
||||
<a-button size="large" @click="store.goPrevPhase">
|
||||
<LeftOutlined /> 上一步
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!store.canGenerate"
|
||||
@click="store.goNextPhase"
|
||||
>
|
||||
下一步:生成视频 <RightOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
SoundOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import VoiceSelector from '@/components/VoiceSelector.vue'
|
||||
import TimelinePanel from './TimelinePanel.vue'
|
||||
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
|
||||
import { formatDurationMs } from '../utils/format'
|
||||
|
||||
const store = useDigitalHumanStore()
|
||||
|
||||
const suggestedChars = computed(() => {
|
||||
return Math.floor((store.faceDurationMs || 10000) / 1000 * 4)
|
||||
})
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (store.faceDurationMs > 0) {
|
||||
return `请输入播报文案,建议不超过 ${suggestedChars.value} 字以确保与视频匹配`
|
||||
}
|
||||
return '请输入你想让角色说话的内容'
|
||||
})
|
||||
|
||||
const canGenerateAudio = computed(() => {
|
||||
return store.text.trim() && store.voice && store.isVideoReady
|
||||
})
|
||||
|
||||
const rateMarks = {
|
||||
0.5: '0.5x',
|
||||
1.0: '1.0x',
|
||||
1.5: '1.5x',
|
||||
2.0: '2.0x',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.voice-config-step {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
border-radius: 8px 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ant-input) {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
padding: 16px 12px;
|
||||
background: #f8fafc;
|
||||
|
||||
&:focus {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.voice-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rate-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rate-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rate-slider {
|
||||
flex: 1;
|
||||
|
||||
:deep(.ant-slider-mark-text) {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #dcfce7;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
|
||||
.success-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fee2e2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.ant-btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,23 @@
|
||||
/**
|
||||
* @fileoverview 数字人合成 Store - 单一状态管理
|
||||
* @fileoverview 数字人合成 Store - 三步骤分步流程
|
||||
*
|
||||
* 设计理念:
|
||||
* 1. 单一状态源 - 所有状态集中管理
|
||||
* 2. 简单直观 - 一个 generate() 方法完成全流程
|
||||
* 3. 易于调试 - 断点打在这里即可
|
||||
* 1. 三步骤流程:选择视频 → 添加配音 → 生成视频
|
||||
* 2. 自动识别:选择视频后自动触发人脸识别
|
||||
* 3. 时间轴可视化:实时对比视频和音频时长
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { uploadAndIdentifyVideo, identifyUploadedVideo, createLipSyncTask } from '@/api/kling'
|
||||
import { identifyFace, createLipSyncTask } from '@/api/kling'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
|
||||
import type { VoiceMeta, Video } from '../types/identify-face'
|
||||
import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep, TimelineData } from '../types/identify-face'
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
/** 流程步骤 */
|
||||
export type GenerateStep = 'idle' | 'uploading' | 'recognizing' | 'generating' | 'creating' | 'done' | 'error'
|
||||
// ========== 内部类型定义 ==========
|
||||
|
||||
/** 音频数据 */
|
||||
interface AudioData {
|
||||
@@ -39,7 +38,7 @@ interface IdentifyData {
|
||||
// ========== Store 定义 ==========
|
||||
|
||||
export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
// ==================== 状态 ====================
|
||||
// ==================== 基础状态 ====================
|
||||
|
||||
/** 文案内容 */
|
||||
const text = ref('')
|
||||
@@ -62,9 +61,6 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
/** 视频预览URL */
|
||||
const videoPreviewUrl = ref('')
|
||||
|
||||
/** 当前步骤 */
|
||||
const step = ref<GenerateStep>('idle')
|
||||
|
||||
/** 错误信息 */
|
||||
const error = ref('')
|
||||
|
||||
@@ -77,27 +73,71 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
/** 视频选择器可见性 */
|
||||
const videoSelectorVisible = ref(false)
|
||||
|
||||
// ==================== 三步骤流程状态(新增) ====================
|
||||
|
||||
/** 当前主阶段 */
|
||||
const phase = ref<PipelinePhase>('select-video')
|
||||
|
||||
/** 视频步骤状态 */
|
||||
const videoStep = ref<VideoStep>('idle')
|
||||
|
||||
/** 音频步骤状态 */
|
||||
const audioStep = ref<AudioStep>('idle')
|
||||
|
||||
/** 生成步骤状态 */
|
||||
const createStep = ref<CreateStep>('idle')
|
||||
|
||||
/** 时间轴数据 */
|
||||
const timeline = ref<TimelineData | null>(null)
|
||||
|
||||
// ==================== 兼容性状态(保留原 step 状态) ====================
|
||||
|
||||
/** 兼容旧流程的状态 */
|
||||
const step = computed(() => {
|
||||
// 映射新状态到旧状态
|
||||
if (videoStep.value === 'uploading') return 'uploading'
|
||||
if (videoStep.value === 'recognizing') return 'recognizing'
|
||||
if (audioStep.value === 'generating') return 'generating'
|
||||
if (createStep.value === 'creating') return 'creating'
|
||||
if (createStep.value === 'done') return 'done'
|
||||
if (videoStep.value === 'error' || audioStep.value === 'error' || createStep.value === 'error') return 'error'
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 是否有视频 */
|
||||
const hasVideo = computed(() => !!(videoFile.value || selectedVideo.value))
|
||||
|
||||
/** 是否可以生成 */
|
||||
const canGenerate = computed(() => {
|
||||
if (step.value !== 'idle') return false
|
||||
return !!(text.value.trim() && voice.value && hasVideo.value)
|
||||
/** 视频阶段是否完成 */
|
||||
const isVideoReady = computed(() => videoStep.value === 'recognized')
|
||||
|
||||
/** 音频阶段是否完成 */
|
||||
const isAudioReady = computed(() => audioStep.value === 'generated')
|
||||
|
||||
/** 是否可以进入下一步 */
|
||||
const canGoNext = computed(() => {
|
||||
if (phase.value === 'select-video') return isVideoReady.value
|
||||
if (phase.value === 'add-voice') return isAudioReady.value
|
||||
return false
|
||||
})
|
||||
|
||||
/** 是否正在处理 */
|
||||
const isBusy = computed(() =>
|
||||
['uploading', 'recognizing', 'generating', 'creating'].includes(step.value)
|
||||
)
|
||||
/** 是否可以生成(步骤3) */
|
||||
const canGenerate = computed(() => {
|
||||
if (!isVideoReady.value || !isAudioReady.value) return false
|
||||
if (!timeline.value) return false
|
||||
// 音频时长不能超过人脸时长
|
||||
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
|
||||
})
|
||||
|
||||
/** 是否完成 */
|
||||
const isDone = computed(() => step.value === 'done')
|
||||
|
||||
/** 是否失败 */
|
||||
const isFailed = computed(() => step.value === 'error')
|
||||
/** 时间轴匹配状态 */
|
||||
const timelineMatch = computed(() => {
|
||||
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
|
||||
const { videoDurationMs, audioDurationMs } = timeline.value
|
||||
if (audioDurationMs > videoDurationMs) return 'exceed'
|
||||
if (audioDurationMs < videoDurationMs * 0.3) return 'too-short'
|
||||
return 'match'
|
||||
})
|
||||
|
||||
/** 人脸时长(ms) */
|
||||
const faceDurationMs = computed(() => {
|
||||
@@ -105,36 +145,49 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
return identifyData.value.faceEndTime - identifyData.value.faceStartTime
|
||||
})
|
||||
|
||||
/** 音频时长(ms) */
|
||||
const audioDurationMs = computed(() => audioData.value?.durationMs || 0)
|
||||
|
||||
/** 是否正在处理 */
|
||||
const isBusy = computed(() =>
|
||||
['uploading', 'recognizing'].includes(videoStep.value) ||
|
||||
audioStep.value === 'generating' ||
|
||||
createStep.value === 'creating'
|
||||
)
|
||||
|
||||
/** 是否完成 */
|
||||
const isDone = computed(() => createStep.value === 'done')
|
||||
|
||||
/** 是否失败 */
|
||||
const isFailed = computed(() =>
|
||||
videoStep.value === 'error' ||
|
||||
audioStep.value === 'error' ||
|
||||
createStep.value === 'error'
|
||||
)
|
||||
|
||||
/** 步骤进度 (0-100) */
|
||||
const progress = computed(() => {
|
||||
const stepProgress: Record<GenerateStep, number> = {
|
||||
idle: 0,
|
||||
uploading: 20,
|
||||
recognizing: 40,
|
||||
generating: 60,
|
||||
creating: 80,
|
||||
done: 100,
|
||||
error: 0,
|
||||
}
|
||||
return stepProgress[step.value]
|
||||
if (createStep.value === 'done') return 100
|
||||
if (createStep.value === 'creating') return 80
|
||||
if (audioStep.value === 'generated') return 60
|
||||
if (audioStep.value === 'generating') return 50
|
||||
if (videoStep.value === 'recognized') return 40
|
||||
if (videoStep.value === 'recognizing') return 30
|
||||
if (videoStep.value === 'uploading') return 20
|
||||
return 0
|
||||
})
|
||||
|
||||
/** 步骤标签 */
|
||||
const stepLabel = computed(() => {
|
||||
const labels: Record<GenerateStep, string> = {
|
||||
idle: '准备就绪',
|
||||
uploading: '上传视频',
|
||||
recognizing: '识别人脸',
|
||||
generating: '生成音频',
|
||||
creating: '创建任务',
|
||||
done: '完成',
|
||||
error: '失败',
|
||||
}
|
||||
return labels[step.value]
|
||||
if (createStep.value === 'creating') return '创建任务'
|
||||
if (audioStep.value === 'generating') return '生成音频'
|
||||
if (videoStep.value === 'recognizing') return '识别人脸'
|
||||
if (videoStep.value === 'uploading') return '上传视频'
|
||||
if (createStep.value === 'done') return '完成'
|
||||
return '准备就绪'
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
// ==================== 方法:基础设置 ====================
|
||||
|
||||
/** 设置音色 */
|
||||
function setVoice(v: VoiceMeta) {
|
||||
@@ -157,7 +210,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
resetProcess()
|
||||
}
|
||||
|
||||
/** 处理文件上传 */
|
||||
// ==================== 方法:步骤1 - 视频选择与识别 ====================
|
||||
|
||||
/** 处理文件上传(上传后自动识别) */
|
||||
async function handleFileUpload(file: File) {
|
||||
if (!file.name.match(/\.(mp4|mov)$/i)) {
|
||||
message.error('仅支持 MP4 和 MOV 格式')
|
||||
@@ -173,25 +228,276 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
videoPreviewUrl.value = URL.createObjectURL(file)
|
||||
selectedVideo.value = null
|
||||
videoSource.value = 'upload'
|
||||
resetProcess()
|
||||
|
||||
// 自动识别
|
||||
await recognizeVideo()
|
||||
}
|
||||
|
||||
/** 从素材库选择视频 */
|
||||
function selectVideo(video: Video) {
|
||||
/** 从素材库选择视频(选择后自动识别) */
|
||||
async function selectVideo(video: Video) {
|
||||
selectedVideo.value = video
|
||||
videoPreviewUrl.value = video.fileUrl
|
||||
videoFile.value = null
|
||||
videoSource.value = 'select'
|
||||
videoSelectorVisible.value = false
|
||||
resetProcess()
|
||||
// 素材列表返回的 fileUrl 已带签名,直接使用
|
||||
videoPreviewUrl.value = video.fileUrl
|
||||
|
||||
// 自动识别
|
||||
await recognizeVideo()
|
||||
}
|
||||
|
||||
/** 识别视频(步骤1核心方法) */
|
||||
async function recognizeVideo() {
|
||||
if (!hasVideo.value) return
|
||||
|
||||
videoStep.value = videoFile.value ? 'uploading' : 'recognizing'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
let result: IdentifyData
|
||||
|
||||
if (selectedVideo.value) {
|
||||
// 素材库视频 - 直接识别
|
||||
videoStep.value = 'recognizing'
|
||||
result = await recognizeExistingVideo(selectedVideo.value)
|
||||
} else if (videoFile.value) {
|
||||
// 上传新视频 - 上传并识别
|
||||
result = await uploadAndRecognizeVideo(videoFile.value)
|
||||
} else {
|
||||
throw new Error('请先选择视频')
|
||||
}
|
||||
|
||||
identifyData.value = result
|
||||
videoStep.value = 'recognized'
|
||||
|
||||
// 更新时间轴
|
||||
timeline.value = {
|
||||
videoDurationMs: result.faceEndTime - result.faceStartTime,
|
||||
audioDurationMs: 0,
|
||||
faceStartTime: result.faceStartTime,
|
||||
faceEndTime: result.faceEndTime,
|
||||
}
|
||||
|
||||
message.success('人脸识别成功')
|
||||
|
||||
} catch (err: any) {
|
||||
videoStep.value = 'error'
|
||||
error.value = err.message || '识别失败'
|
||||
message.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传并识别新视频 */
|
||||
async function uploadAndRecognizeVideo(file: File): Promise<IdentifyData> {
|
||||
// 1. 使用 useUpload 直传文件
|
||||
const { upload } = useUpload()
|
||||
const fileId = await upload(file, { fileCategory: 'digital_human' } as any)
|
||||
|
||||
// 2. 获取播放 URL
|
||||
const urlRes = await MaterialService.getVideoPlayUrl(Number(fileId))
|
||||
if (urlRes.code !== 0 || !urlRes.data) {
|
||||
throw new Error(urlRes.msg || '获取播放链接失败')
|
||||
}
|
||||
|
||||
// 3. 执行人脸识别
|
||||
videoStep.value = 'recognizing'
|
||||
return performFaceRecognition(fileId, urlRes.data, true)
|
||||
}
|
||||
|
||||
/** 识别已存在的视频 */
|
||||
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
|
||||
// 素材列表返回的 fileUrl 已带签名,直接使用
|
||||
return performFaceRecognition(video.id, video.fileUrl, false)
|
||||
}
|
||||
|
||||
/** 执行人脸识别 */
|
||||
async function performFaceRecognition(fileId: number | string, videoUrl: string, isUploadedFile: boolean): Promise<IdentifyData> {
|
||||
const identifyRes = await identifyFace({ video_url: videoUrl })
|
||||
if (identifyRes.code !== 0) {
|
||||
throw new Error(identifyRes.msg || '识别失败')
|
||||
}
|
||||
|
||||
const faceData = identifyRes.data.data?.face_data?.[0]
|
||||
const startTime = faceData?.start_time || 0
|
||||
const endTime = faceData?.end_time || 0
|
||||
|
||||
return {
|
||||
fileId: String(fileId),
|
||||
sessionId: identifyRes.data.sessionId,
|
||||
faceId: faceData?.face_id || '',
|
||||
faceStartTime: isUploadedFile ? Math.round(startTime) : startTime,
|
||||
faceEndTime: isUploadedFile ? Math.round(endTime) : endTime,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 方法:步骤2 - 音频生成 ====================
|
||||
|
||||
/** 生成音频(步骤2核心方法) */
|
||||
async function generateAudio() {
|
||||
if (!text.value.trim()) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
if (!voice.value) {
|
||||
message.warning('请选择音色')
|
||||
return
|
||||
}
|
||||
if (!isVideoReady.value) {
|
||||
message.warning('请先完成视频识别')
|
||||
return
|
||||
}
|
||||
|
||||
audioStep.value = 'generating'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const voiceId = voice.value.rawId ?? extractId(voice.value.id)
|
||||
|
||||
const res = await VoiceService.synthesize({
|
||||
inputText: text.value,
|
||||
voiceConfigId: voiceId,
|
||||
speechRate: speechRate.value,
|
||||
audioFormat: 'mp3',
|
||||
providerType: DEFAULT_VOICE_PROVIDER,
|
||||
} as any)
|
||||
|
||||
if (res.code !== 0 || !res.data?.audioBase64) {
|
||||
throw new Error(res.msg || '音频生成失败')
|
||||
}
|
||||
|
||||
const durationMs = await parseAudioDuration(res.data.audioBase64)
|
||||
|
||||
audioData.value = {
|
||||
audioBase64: res.data.audioBase64,
|
||||
format: 'mp3',
|
||||
durationMs,
|
||||
}
|
||||
|
||||
audioStep.value = 'generated'
|
||||
|
||||
// 更新时间轴
|
||||
if (timeline.value) {
|
||||
timeline.value.audioDurationMs = durationMs
|
||||
}
|
||||
|
||||
// 检查时长是否匹配
|
||||
if (durationMs > faceDurationMs.value) {
|
||||
message.warning(`音频时长(${(durationMs/1000).toFixed(1)}秒)超过人脸时长,请缩短文案`)
|
||||
} else {
|
||||
message.success('音频生成成功')
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
audioStep.value = 'error'
|
||||
error.value = err.message || '音频生成失败'
|
||||
message.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 方法:步骤3 - 创建任务 ====================
|
||||
|
||||
/** 创建任务(步骤3核心方法) */
|
||||
async function createTask() {
|
||||
if (!identifyData.value) {
|
||||
message.warning('请先完成视频识别')
|
||||
return
|
||||
}
|
||||
if (!audioData.value) {
|
||||
message.warning('请先生成音频')
|
||||
return
|
||||
}
|
||||
|
||||
// 时长校验
|
||||
if (audioData.value.durationMs > faceDurationMs.value) {
|
||||
message.error('音频时长超过人脸时长,请缩短文案后重试')
|
||||
return
|
||||
}
|
||||
|
||||
createStep.value = 'creating'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const voiceId = voice.value!.rawId ?? extractId(voice.value!.id)
|
||||
|
||||
const taskRes = await createLipSyncTask({
|
||||
taskName: `数字人任务_${Date.now()}`,
|
||||
videoFileId: identifyData.value.fileId,
|
||||
inputText: text.value,
|
||||
speechRate: speechRate.value,
|
||||
volume: 0,
|
||||
guidanceScale: 1,
|
||||
seed: 8888,
|
||||
kling_session_id: identifyData.value.sessionId,
|
||||
kling_face_id: identifyData.value.faceId,
|
||||
kling_face_start_time: identifyData.value.faceStartTime,
|
||||
kling_face_end_time: identifyData.value.faceEndTime,
|
||||
ai_provider: 'kling',
|
||||
voiceConfigId: voiceId,
|
||||
pre_generated_audio: {
|
||||
audioBase64: audioData.value.audioBase64,
|
||||
format: audioData.value.format,
|
||||
},
|
||||
sound_end_time: audioData.value.durationMs,
|
||||
})
|
||||
|
||||
if (taskRes.code !== 0) {
|
||||
throw new Error(taskRes.msg || '任务创建失败')
|
||||
}
|
||||
|
||||
createStep.value = 'done'
|
||||
message.success('任务已提交,请在任务中心查看生成进度')
|
||||
|
||||
} catch (err: any) {
|
||||
createStep.value = 'error'
|
||||
error.value = err.message || '任务创建失败'
|
||||
message.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 方法:阶段导航 ====================
|
||||
|
||||
/** 切换到下一阶段 */
|
||||
function goNextPhase() {
|
||||
if (phase.value === 'select-video' && isVideoReady.value) {
|
||||
phase.value = 'add-voice'
|
||||
} else if (phase.value === 'add-voice' && isAudioReady.value) {
|
||||
phase.value = 'generate'
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回上一阶段 */
|
||||
function goPrevPhase() {
|
||||
if (phase.value === 'add-voice') {
|
||||
phase.value = 'select-video'
|
||||
} else if (phase.value === 'generate') {
|
||||
phase.value = 'add-voice'
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到指定阶段 */
|
||||
function goToPhase(newPhase: PipelinePhase) {
|
||||
// 只能跳转到已完成的阶段或当前阶段的下一阶段
|
||||
if (newPhase === 'select-video') {
|
||||
phase.value = newPhase
|
||||
} else if (newPhase === 'add-voice' && isVideoReady.value) {
|
||||
phase.value = newPhase
|
||||
} else if (newPhase === 'generate' && isAudioReady.value) {
|
||||
phase.value = newPhase
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 方法:重置 ====================
|
||||
|
||||
/** 重置流程状态 */
|
||||
function resetProcess() {
|
||||
step.value = 'idle'
|
||||
videoStep.value = 'idle'
|
||||
audioStep.value = 'idle'
|
||||
createStep.value = 'idle'
|
||||
error.value = ''
|
||||
identifyData.value = null
|
||||
audioData.value = null
|
||||
timeline.value = null
|
||||
phase.value = 'select-video'
|
||||
}
|
||||
|
||||
/** 完全重置 */
|
||||
@@ -208,13 +514,49 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
videoFile.value = null
|
||||
selectedVideo.value = null
|
||||
videoPreviewUrl.value = ''
|
||||
step.value = 'idle'
|
||||
error.value = ''
|
||||
identifyData.value = null
|
||||
audioData.value = null
|
||||
videoSelectorVisible.value = false
|
||||
|
||||
resetProcess()
|
||||
}
|
||||
|
||||
/** 重试当前步骤 */
|
||||
function retry() {
|
||||
error.value = ''
|
||||
|
||||
if (videoStep.value === 'error') {
|
||||
videoStep.value = 'idle'
|
||||
recognizeVideo()
|
||||
} else if (audioStep.value === 'error') {
|
||||
audioStep.value = 'idle'
|
||||
generateAudio()
|
||||
} else if (createStep.value === 'error') {
|
||||
createStep.value = 'idle'
|
||||
createTask()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 方法:兼容旧流程 ====================
|
||||
|
||||
/** 一键生成(兼容旧版) */
|
||||
async function generate() {
|
||||
// 步骤1
|
||||
if (!isVideoReady.value) {
|
||||
await recognizeVideo()
|
||||
if (videoStep.value === 'error') return
|
||||
}
|
||||
|
||||
// 步骤2
|
||||
if (!isAudioReady.value) {
|
||||
await generateAudio()
|
||||
if (audioStep.value === 'error') return
|
||||
}
|
||||
|
||||
// 步骤3
|
||||
await createTask()
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
/** 解析音频时长 */
|
||||
async function parseAudioDuration(base64Data: string): Promise<number> {
|
||||
const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data
|
||||
@@ -248,7 +590,6 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
|
||||
lastDuration = duration
|
||||
|
||||
// 只在 canplaythrough 时 resolve,此时时长最准确
|
||||
if (source === 'canplaythrough') {
|
||||
resolved = true
|
||||
cleanup()
|
||||
@@ -256,19 +597,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// VBR MP3 早期事件估算不准,等待 canplaythrough
|
||||
audio.ondurationchange = () => {
|
||||
tryResolve(audio.duration, 'durationchange')
|
||||
}
|
||||
|
||||
audio.oncanplay = () => {
|
||||
tryResolve(audio.duration, 'canplay')
|
||||
}
|
||||
|
||||
audio.oncanplaythrough = () => {
|
||||
tryResolve(audio.duration, 'canplaythrough')
|
||||
}
|
||||
|
||||
audio.ondurationchange = () => tryResolve(audio.duration, 'durationchange')
|
||||
audio.oncanplay = () => tryResolve(audio.duration, 'canplay')
|
||||
audio.oncanplaythrough = () => tryResolve(audio.duration, 'canplaythrough')
|
||||
audio.onerror = () => {
|
||||
if (!resolved) {
|
||||
if (lastDuration > 0) {
|
||||
@@ -287,129 +618,10 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
})
|
||||
}
|
||||
|
||||
/** 生成数字人视频 - 主流程 */
|
||||
async function generate() {
|
||||
// 校验
|
||||
if (!text.value.trim()) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
if (!voice.value) {
|
||||
message.warning('请选择音色')
|
||||
return
|
||||
}
|
||||
if (!hasVideo.value) {
|
||||
message.warning('请先选择视频')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// ===== 步骤1: 上传并识别 =====
|
||||
step.value = videoFile.value ? 'uploading' : 'recognizing'
|
||||
|
||||
let identifyResult: IdentifyData
|
||||
if (selectedVideo.value) {
|
||||
// 素材库视频 - 直接识别
|
||||
step.value = 'recognizing'
|
||||
const res = await identifyUploadedVideo(selectedVideo.value) as any
|
||||
identifyResult = {
|
||||
fileId: String(selectedVideo.value.fileId),
|
||||
sessionId: res.data.sessionId,
|
||||
faceId: res.data.faceId || '',
|
||||
faceStartTime: res.data.startTime || 0,
|
||||
faceEndTime: res.data.endTime || 0,
|
||||
}
|
||||
} else {
|
||||
// 上传新视频 - 上传并识别
|
||||
const res = await uploadAndIdentifyVideo(videoFile.value!) as any
|
||||
identifyResult = {
|
||||
fileId: String(res.data.fileId),
|
||||
sessionId: res.data.sessionId,
|
||||
faceId: res.data.faceId || '',
|
||||
faceStartTime: res.data.startTime || 0,
|
||||
faceEndTime: res.data.endTime || 0,
|
||||
}
|
||||
}
|
||||
identifyData.value = identifyResult
|
||||
|
||||
// ===== 步骤2: 生成音频 =====
|
||||
step.value = 'generating'
|
||||
|
||||
const voiceId = voice.value.rawId ?? extractId(voice.value.id)
|
||||
const res = await VoiceService.synthesize({
|
||||
inputText: text.value,
|
||||
voiceConfigId: voiceId,
|
||||
speechRate: speechRate.value,
|
||||
audioFormat: 'mp3',
|
||||
providerType: DEFAULT_VOICE_PROVIDER,
|
||||
} as any)
|
||||
|
||||
if (res.code !== 0 || !res.data?.audioBase64) {
|
||||
throw new Error(res.msg || '音频生成失败')
|
||||
}
|
||||
|
||||
const durationMs = await parseAudioDuration(res.data.audioBase64)
|
||||
audioData.value = {
|
||||
audioBase64: res.data.audioBase64,
|
||||
format: 'mp3',
|
||||
durationMs,
|
||||
}
|
||||
|
||||
// ===== 步骤3: 校验时长 =====
|
||||
const videoDurationMs = identifyResult.faceEndTime - identifyResult.faceStartTime
|
||||
if (durationMs > videoDurationMs) {
|
||||
throw new Error(`音频时长(${(durationMs/1000).toFixed(1)}秒)超过人脸时长(${(videoDurationMs/1000).toFixed(1)}秒)`)
|
||||
}
|
||||
|
||||
// ===== 步骤4: 创建任务 =====
|
||||
step.value = 'creating'
|
||||
|
||||
const taskRes = await createLipSyncTask({
|
||||
taskName: `数字人任务_${Date.now()}`,
|
||||
videoFileId: identifyResult.fileId,
|
||||
inputText: text.value,
|
||||
speechRate: speechRate.value,
|
||||
volume: 0,
|
||||
guidanceScale: 1,
|
||||
seed: 8888,
|
||||
kling_session_id: identifyResult.sessionId,
|
||||
kling_face_id: identifyResult.faceId,
|
||||
kling_face_start_time: identifyResult.faceStartTime,
|
||||
kling_face_end_time: identifyResult.faceEndTime,
|
||||
ai_provider: 'kling',
|
||||
voiceConfigId: voiceId,
|
||||
pre_generated_audio: {
|
||||
audioBase64: audioData.value.audioBase64,
|
||||
format: audioData.value.format,
|
||||
},
|
||||
sound_end_time: audioData.value.durationMs,
|
||||
})
|
||||
|
||||
if (taskRes.code !== 0) {
|
||||
throw new Error(taskRes.msg || '任务创建失败')
|
||||
}
|
||||
|
||||
step.value = 'done'
|
||||
message.success('任务已提交,请在任务中心查看生成进度')
|
||||
|
||||
} catch (err: any) {
|
||||
step.value = 'error'
|
||||
error.value = err.message || '生成失败'
|
||||
message.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 重试 */
|
||||
function retry() {
|
||||
if (step.value === 'error') {
|
||||
resetProcess()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
// 基础状态
|
||||
text,
|
||||
speechRate,
|
||||
voice,
|
||||
@@ -417,32 +629,64 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
|
||||
videoFile,
|
||||
selectedVideo,
|
||||
videoPreviewUrl,
|
||||
step,
|
||||
error,
|
||||
identifyData,
|
||||
audioData,
|
||||
videoSelectorVisible,
|
||||
|
||||
// 三步骤流程状态(新增)
|
||||
phase,
|
||||
videoStep,
|
||||
audioStep,
|
||||
createStep,
|
||||
timeline,
|
||||
|
||||
// 兼容旧状态
|
||||
step,
|
||||
|
||||
// 计算属性
|
||||
hasVideo,
|
||||
isVideoReady,
|
||||
isAudioReady,
|
||||
canGoNext,
|
||||
canGenerate,
|
||||
timelineMatch,
|
||||
faceDurationMs,
|
||||
audioDurationMs,
|
||||
isBusy,
|
||||
isDone,
|
||||
isFailed,
|
||||
faceDurationMs,
|
||||
progress,
|
||||
stepLabel,
|
||||
|
||||
// 方法
|
||||
// 方法:基础
|
||||
setVoice,
|
||||
selectUploadMode,
|
||||
selectLibraryMode,
|
||||
|
||||
// 方法:步骤1
|
||||
handleFileUpload,
|
||||
selectVideo,
|
||||
recognizeVideo,
|
||||
|
||||
// 方法:步骤2
|
||||
generateAudio,
|
||||
|
||||
// 方法:步骤3
|
||||
createTask,
|
||||
|
||||
// 方法:导航
|
||||
goNextPhase,
|
||||
goPrevPhase,
|
||||
goToPhase,
|
||||
|
||||
// 方法:重置
|
||||
resetProcess,
|
||||
reset,
|
||||
generate,
|
||||
retry,
|
||||
|
||||
// 兼容旧方法
|
||||
generate,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,33 @@
|
||||
* @author Claude Code
|
||||
*/
|
||||
|
||||
// ========== 三步骤流程状态 ==========
|
||||
|
||||
/** 主流程阶段 */
|
||||
export type PipelinePhase = 'select-video' | 'add-voice' | 'generate'
|
||||
|
||||
/** 视频步骤状态 */
|
||||
export type VideoStep = 'idle' | 'uploading' | 'recognizing' | 'recognized' | 'error'
|
||||
|
||||
/** 音频步骤状态 */
|
||||
export type AudioStep = 'idle' | 'generating' | 'generated' | 'error'
|
||||
|
||||
/** 生成步骤状态 */
|
||||
export type CreateStep = 'idle' | 'creating' | 'done' | 'error'
|
||||
|
||||
/** 时间轴匹配状态 */
|
||||
export type TimelineMatchStatus = 'match' | 'exceed' | 'too-short' | 'none'
|
||||
|
||||
/** 时间轴数据 */
|
||||
export interface TimelineData {
|
||||
videoDurationMs: number // 视频人脸时长
|
||||
audioDurationMs: number // 音频时长
|
||||
faceStartTime: number // 人脸起始时间
|
||||
faceEndTime: number // 人脸结束时间
|
||||
}
|
||||
|
||||
// ========== 原有类型定义 ==========
|
||||
|
||||
/**
|
||||
* 视频状态接口
|
||||
*/
|
||||
@@ -27,7 +54,7 @@ export interface Video {
|
||||
fileSize: number
|
||||
duration: number
|
||||
coverBase64?: string
|
||||
previewUrl?: string
|
||||
imgUrl?: string
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
|
||||
41
frontend/app/web-gold/src/views/kling/utils/format.ts
Normal file
41
frontend/app/web-gold/src/views/kling/utils/format.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 数字人模块格式化工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化毫秒时长为 m:ss 格式
|
||||
* @param ms 毫秒数
|
||||
* @returns 格式化后的时间字符串,如 "1:30"
|
||||
*/
|
||||
export function formatDurationMs(ms: number): string {
|
||||
if (!ms || ms <= 0) return '0:00'
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化秒数为 mm:ss 格式
|
||||
* @param seconds 秒数
|
||||
* @returns 格式化后的时间字符串,如 "01:30"
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '--:--'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的大小字符串,如 "1.5 MB"
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]
|
||||
}
|
||||
@@ -152,8 +152,8 @@
|
||||
<!-- 预览图 -->
|
||||
<div class="material-item__preview">
|
||||
<img
|
||||
v-if="file.previewUrl"
|
||||
:src="file.previewUrl"
|
||||
v-if="file.imgUrl"
|
||||
:src="file.imgUrl"
|
||||
:alt="file.fileName"
|
||||
@error="handleImageError"
|
||||
loading="lazy"
|
||||
|
||||
@@ -159,8 +159,8 @@
|
||||
<template #item="{ element: candidate, index: cIndex }">
|
||||
<div class="candidate-thumb">
|
||||
<img
|
||||
v-if="getFileById(candidate.fileId)?.previewUrl"
|
||||
:src="getFileById(candidate.fileId).previewUrl"
|
||||
v-if="getFileById(candidate.fileId)?.imgUrl"
|
||||
:src="getFileById(candidate.fileId).imgUrl"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
@dblclick="handleQuickConfirm(file)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img v-if="file.previewUrl" :src="file.previewUrl" />
|
||||
<img v-if="file.imgUrl" :src="file.imgUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
# 积分系统与OSS额度系统实施计划
|
||||
|
||||
> 创建日期:2026-02-25
|
||||
> 状态:待审批
|
||||
|
||||
---
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
### 1.1 业务需求
|
||||
|
||||
| 模块 | 需求描述 |
|
||||
|------|----------|
|
||||
| **前端积分系统** | 前端启动时加载 `muye_ai_model_config`,根据 model_code 获取积分消耗,显示在对标分析、热点趋势、智能体、数字人模块 |
|
||||
| **OSS额度系统** | 后端统一用字节计算,仅统计已上传未删除文件,上传前校验,删除后释放,每日凌晨自动对账 |
|
||||
| **OSS存储管理** | 素材列表和个人中心显示用户正确的额度 |
|
||||
|
||||
### 1.2 验收标准
|
||||
|
||||
1. 前端各业务模块正确显示积分消耗数值
|
||||
2. OSS 上传前校验额度,超额拒绝上传
|
||||
3. OSS 文件删除后实时释放额度
|
||||
4. 每日凌晨自动对账,确保数据库记录与实际一致
|
||||
|
||||
---
|
||||
|
||||
## 二、现有架构分析
|
||||
|
||||
### 2.1 已有基础设施
|
||||
|
||||
| 组件 | 位置 | 状态 |
|
||||
|------|------|------|
|
||||
| **积分服务** | `PointsService` | ✅ 已实现预检、扣减、预扣逻辑 |
|
||||
| **AI模型配置** | `muye_ai_model_config` 表 | ✅ 已有 `consume_points` 字段 |
|
||||
| **用户档案服务** | `MemberUserProfileService` | ⚠️ 需增强存储校验和更新 |
|
||||
| **用户档案表** | `muye_member_user_profile` | ✅ 已有存储和积分字段(GB单位) |
|
||||
| **用户Store** | `stores/user.js` | ✅ 已有积分和存储计算属性 |
|
||||
| **个人中心** | `views/user/Profile.vue` | ✅ 已显示基本额度信息 |
|
||||
|
||||
### 2.2 需要新增的组件
|
||||
|
||||
| 组件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ModelConfigApi` | 前端API | 获取模型配置列表 |
|
||||
| `PointsConfigStore` | 前端Store | 管理积分配置状态 |
|
||||
| `PointsTag` | 前端组件 | 积分消耗标签组件 |
|
||||
| `OssQuotaReconcileJob` | 后端Job | 每日对账任务 |
|
||||
| `StorageQuotaMixin` | 前端Composable | 存储额度显示逻辑复用 |
|
||||
|
||||
---
|
||||
|
||||
## 三、详细设计
|
||||
|
||||
### 3.1 前端积分系统
|
||||
|
||||
#### 3.1.1 数据流
|
||||
|
||||
```
|
||||
前端启动 → 调用 API 获取模型配置 → 存入 Store → 各模块从 Store 读取显示
|
||||
```
|
||||
|
||||
#### 3.1.2 新增文件
|
||||
|
||||
```
|
||||
frontend/app/web-gold/src/
|
||||
├── api/
|
||||
│ └── modelConfig.js # 模型配置 API
|
||||
├── stores/
|
||||
│ └── pointsConfig.js # 积分配置 Store
|
||||
└── components/
|
||||
└── common/
|
||||
└── PointsTag.vue # 积分消耗标签组件
|
||||
```
|
||||
|
||||
#### 3.1.3 API 设计
|
||||
|
||||
```javascript
|
||||
// api/modelConfig.js
|
||||
export function getModelConfigList() {
|
||||
return request({
|
||||
url: `${BASE_URL}/aimodelconfig/list-enabled`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 返回格式
|
||||
{
|
||||
"dify": {
|
||||
"agent_chat_pro": { "consumePoints": 10, "modelName": "深度版" },
|
||||
"agent_chat_standard": { "consumePoints": 5, "modelName": "标准版" }
|
||||
},
|
||||
"digital_human": {
|
||||
"latentsync": { "consumePoints": 50, "modelName": "数字人" }
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.4 Store 设计
|
||||
|
||||
```javascript
|
||||
// stores/pointsConfig.js
|
||||
export const usePointsConfigStore = defineStore('pointsConfig', () => {
|
||||
const configMap = ref({}) // 按平台+modelCode组织的配置
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 根据 platform 和 modelCode 获取积分消耗
|
||||
const getConsumePoints = (platform, modelCode) => {
|
||||
return configMap.value[platform]?.[modelCode]?.consumePoints ?? 0
|
||||
}
|
||||
|
||||
// 初始化加载配置
|
||||
const loadConfig = async () => {
|
||||
if (isLoaded.value) return
|
||||
const data = await getModelConfigList()
|
||||
configMap.value = data
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
return { configMap, isLoaded, getConsumePoints, loadConfig }
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.1.5 积分标签组件
|
||||
|
||||
```vue
|
||||
<!-- components/common/PointsTag.vue -->
|
||||
<template>
|
||||
<span class="points-tag" :class="{ 'is-loading': loading }">
|
||||
<ThunderboltOutlined />
|
||||
<span class="points-value">{{ displayPoints }}</span>
|
||||
<span class="points-unit">积分</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
|
||||
const props = defineProps({
|
||||
platform: { type: String, required: true },
|
||||
modelCode: { type: String, required: true },
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
const displayPoints = computed(() =>
|
||||
pointsConfigStore.getConsumePoints(props.platform, props.modelCode)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 3.1.6 各模块集成点
|
||||
|
||||
| 模块 | 文件位置 | 集成方式 |
|
||||
|------|----------|----------|
|
||||
| **智能体** | `ChatDrawer.vue` | 在发送按钮旁显示积分消耗 |
|
||||
| **数字人** | `Video.vue` | 在生成按钮旁显示积分消耗 |
|
||||
| **对标分析** | `Benchmark.vue` | 在分析按钮旁显示积分消耗 |
|
||||
| **热点趋势** | `Forecast.vue` | 在文案生成按钮旁显示积分消耗 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 OSS 额度系统
|
||||
|
||||
#### 3.2.1 数据模型
|
||||
|
||||
**MemberUserProfileDO 字段(muye_member_user_profile 表):**
|
||||
```java
|
||||
private String userId; // 用户ID
|
||||
private BigDecimal totalStorage; // 云空间总容量 (GB)
|
||||
private BigDecimal usedStorage; // 云空间已用容量 (GB)
|
||||
private BigDecimal remainingStorage;// 云空间剩余容量 (GB)
|
||||
private Integer totalPoints; // 账户总积分
|
||||
private Integer usedPoints; // 账户消耗积分
|
||||
private Integer remainingPoints; // 账户剩余积分
|
||||
```
|
||||
|
||||
> **设计决策**:保持 GB 单位存储(兼容现有数据),后端逻辑统一用字节计算
|
||||
|
||||
**存储文件记录表(muye_material_file):**
|
||||
```sql
|
||||
-- 文件大小字段(字节)
|
||||
file_size BIGINT NOT NULL COMMENT '文件大小(字节)'
|
||||
```
|
||||
|
||||
**单位转换常量:**
|
||||
```java
|
||||
// 1 GB = 1024 * 1024 * 1024 字节
|
||||
public static final long BYTES_PER_GB = 1073741824L;
|
||||
```
|
||||
|
||||
#### 3.2.2 后端增强设计
|
||||
|
||||
**修改/新增文件:**
|
||||
|
||||
```
|
||||
yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/
|
||||
├── muye/
|
||||
│ └── memberuserprofile/
|
||||
│ ├── service/
|
||||
│ │ ├── MemberUserProfileService.java # 增强:添加存储额度方法
|
||||
│ │ └── MemberUserProfileServiceImpl.java
|
||||
│ ├── mapper/
|
||||
│ │ └── MemberUserProfileMapper.java # 增强:添加原子更新方法
|
||||
│ └── job/
|
||||
│ └── OssQuotaReconcileJob.java # 新增:每日对账Job
|
||||
```
|
||||
|
||||
#### 3.2.3 额度校验流程
|
||||
|
||||
```
|
||||
用户上传文件
|
||||
↓
|
||||
检查文件大小(字节)
|
||||
↓
|
||||
调用 MemberUserProfileService.validateStorage(userId, fileSizeBytes)
|
||||
↓
|
||||
内部逻辑:将 fileSizeBytes 转换为 GB,与 remainingStorage 比较
|
||||
↓
|
||||
├── 余额充足 → 允许上传
|
||||
│ ↓
|
||||
│ 文件上传成功 → 调用 increaseUsedStorage(userId, fileSizeBytes)
|
||||
│
|
||||
└── 余额不足 → 抛出异常,拒绝上传
|
||||
```
|
||||
|
||||
#### 3.2.4 额度释放流程
|
||||
|
||||
```
|
||||
用户删除文件
|
||||
↓
|
||||
获取文件大小 fileSizeBytes(字节)
|
||||
↓
|
||||
调用 MemberUserProfileService.decreaseUsedStorage(userId, fileSizeBytes)
|
||||
↓
|
||||
内部逻辑:字节转GB,原子更新 usedStorage/remainingStorage
|
||||
```
|
||||
|
||||
#### 3.2.5 MemberUserProfileService 增强
|
||||
|
||||
```java
|
||||
// 新增方法
|
||||
/**
|
||||
* 校验存储空间是否足够
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
*/
|
||||
void validateStorage(String userId, long fileSizeBytes);
|
||||
|
||||
/**
|
||||
* 增加已使用存储空间
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
*/
|
||||
void increaseUsedStorage(String userId, long fileSizeBytes);
|
||||
|
||||
/**
|
||||
* 减少已使用存储空间
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
*/
|
||||
void decreaseUsedStorage(String userId, long fileSizeBytes);
|
||||
```
|
||||
|
||||
#### 3.2.6 Mapper 原子更新
|
||||
|
||||
```java
|
||||
// MemberUserProfileMapper.java 新增
|
||||
|
||||
/**
|
||||
* 原子增加已用存储(乐观锁)
|
||||
* @param userId 用户ID
|
||||
* @param storageGb 增加的存储量(GB,BigDecimal转String)
|
||||
* @return 影响行数,0表示余额不足
|
||||
*/
|
||||
@Update("UPDATE muye_member_user_profile " +
|
||||
"SET used_storage = used_storage + #{storageGb}, " +
|
||||
" remaining_storage = remaining_storage - #{storageGb}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND remaining_storage >= #{storageGb}")
|
||||
int updateStorageIncrease(@Param("userId") String userId, @Param("storageGb") String storageGb);
|
||||
|
||||
/**
|
||||
* 原子减少已用存储
|
||||
*/
|
||||
@Update("UPDATE muye_member_user_profile " +
|
||||
"SET used_storage = used_storage - #{storageGb}, " +
|
||||
" remaining_storage = remaining_storage + #{storageGb}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND used_storage >= #{storageGb}")
|
||||
int updateStorageDecrease(@Param("userId") String userId, @Param("storageGb") String storageGb);
|
||||
```
|
||||
|
||||
#### 3.2.7 每日对账Job
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class OssQuotaReconcileJob {
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * ?") // 每日凌晨3点
|
||||
public void reconcile() {
|
||||
// 1. 查询所有用户的档案
|
||||
// 2. 统计每个用户 muye_material_file 表中文件总大小(字节)
|
||||
// 3. 转换为 GB,与 usedStorage 对比
|
||||
// 4. 不一致则修正并记录日志
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.8 API 设计
|
||||
|
||||
使用现有的 `/webApi/api/tik/member-profile/get` 接口,返回数据已包含存储额度信息。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 OSS 存储管理
|
||||
|
||||
#### 3.3.1 素材列表集成
|
||||
|
||||
在 `MaterialListNew.vue` 顶部工具栏显示存储额度:
|
||||
|
||||
```vue
|
||||
<div class="storage-quota-info">
|
||||
<DatabaseOutlined />
|
||||
<span>{{ formatStorage(usedStorage) }} / {{ formatStorage(totalStorage) }}</span>
|
||||
<a-progress :percent="storagePercent" size="small" />
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.3.2 个人中心优化
|
||||
|
||||
`Profile.vue` 已有存储空间显示,需要确保数据来源正确:
|
||||
- 从 `userStore.remainingStorage` 读取
|
||||
- 确保 `getUserProfile()` API 返回正确的字节数据
|
||||
|
||||
---
|
||||
|
||||
## 四、任务分解
|
||||
|
||||
### Phase 1: 后端基础(优先级:高)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1.1 | 新增模型配置列表API | `AppAiModelConfigController.java` | 添加 `/list-enabled` 接口 |
|
||||
| 1.2 | 增强用户档案服务 | `MemberUserProfileServiceImpl.java` | 添加存储校验和更新方法 |
|
||||
| 1.3 | 新增Mapper原子更新 | `MemberUserProfileMapper.java` | 添加存储增减的原子操作 |
|
||||
| 1.4 | 新增每日对账Job | `OssQuotaReconcileJob.java` | 实现自动对账 |
|
||||
|
||||
### Phase 2: 前端积分系统(优先级:高)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 2.1 | 创建模型配置API | `api/modelConfig.js` | 封装配置获取接口 |
|
||||
| 2.2 | 创建积分配置Store | `stores/pointsConfig.js` | 管理配置状态 |
|
||||
| 2.3 | 创建积分标签组件 | `components/common/PointsTag.vue` | 可复用的积分显示组件 |
|
||||
| 2.4 | 应用启动时加载配置 | `App.vue` 或入口文件 | 初始化积分配置 |
|
||||
|
||||
### Phase 3: 业务模块集成(优先级:中)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 3.1 | 智能体模块集成 | `ChatDrawer.vue` | 显示对话积分消耗 |
|
||||
| 3.2 | 数字人模块集成 | `dh/Video.vue` | 显示生成积分消耗 |
|
||||
| 3.3 | 对标分析集成 | `Benchmark.vue` | 显示分析积分消耗 |
|
||||
| 3.4 | 热点趋势集成 | `Forecast.vue` | 显示文案生成积分消耗 |
|
||||
|
||||
### Phase 4: OSS存储管理(优先级:中)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 4.1 | 素材列表显示额度 | `MaterialListNew.vue` | 工具栏显示存储额度 |
|
||||
| 4.2 | 上传前额度校验 | `useUpload.js` | 上传前检查额度 |
|
||||
| 4.3 | 删除后释放额度 | 后端文件删除接口 | 调用 decreaseUsedStorage |
|
||||
| 4.4 | 个人中心优化 | `Profile.vue` | 确保显示正确字节值 |
|
||||
|
||||
---
|
||||
|
||||
## 五、风险分析
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
|------|------|------|
|
||||
| 积分配置加载失败 | 前端显示0积分 | 添加默认值和重试机制 |
|
||||
| 并发上传导致额度超限 | 超出配额 | 使用数据库乐观锁或原子操作 |
|
||||
| 对账Job执行时间过长 | 影响系统性能 | 分批处理,添加超时控制 |
|
||||
| 历史数据不一致 | 对账修正幅度大 | 先做数据盘点,再逐步修正 |
|
||||
|
||||
---
|
||||
|
||||
## 六、测试计划
|
||||
|
||||
### 6.1 单元测试
|
||||
|
||||
- [ ] `PointsConfigStore` 状态管理测试
|
||||
- [ ] `TikUserQuotaService` 额度计算测试
|
||||
- [ ] 对账Job逻辑测试
|
||||
|
||||
### 6.2 集成测试
|
||||
|
||||
- [ ] 前端积分显示正确性
|
||||
- [ ] OSS上传额度校验
|
||||
- [ ] OSS删除额度释放
|
||||
- [ ] 每日对账执行
|
||||
|
||||
### 6.3 验收测试
|
||||
|
||||
```
|
||||
Benchark Checklist:
|
||||
1. ✅ 智能体对话页面显示"消耗 X 积分"
|
||||
2. ✅ 数字人生成页面显示"消耗 X 积分"
|
||||
3. ✅ 对标分析页面显示"消耗 X 积分"
|
||||
4. ✅ 热点趋势页面显示"消耗 X 积分"
|
||||
5. ✅ 素材列表显示存储额度进度条
|
||||
6. ✅ 上传大文件超出额度时提示错误
|
||||
7. ✅ 删除文件后额度正确释放
|
||||
8. ✅ 个人中心显示正确的存储额度
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实施顺序建议
|
||||
|
||||
```
|
||||
Week 1: Phase 1 (后端基础) + Phase 2 (前端积分系统)
|
||||
Week 2: Phase 3 (业务模块集成) + Phase 4 (OSS存储管理)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文件清单
|
||||
|
||||
### 后端文件
|
||||
|
||||
| 操作 | 文件路径 |
|
||||
|------|----------|
|
||||
| 修改 | `yudao-module-tik/.../aimodelconfig/AiModelConfigController.java` |
|
||||
| 修改 | `yudao-module-tik/.../memberuserprofile/service/MemberUserProfileService.java` |
|
||||
| 修改 | `yudao-module-tik/.../memberuserprofile/service/MemberUserProfileServiceImpl.java` |
|
||||
| 修改 | `yudao-module-tik/.../memberuserprofile/mapper/MemberUserProfileMapper.java` |
|
||||
| 新增 | `yudao-module-tik/.../memberuserprofile/job/OssQuotaReconcileJob.java` |
|
||||
|
||||
### 前端文件
|
||||
|
||||
| 操作 | 文件路径 |
|
||||
|------|----------|
|
||||
| 新增 | `frontend/app/web-gold/src/api/modelConfig.js` |
|
||||
| 新增 | `frontend/app/web-gold/src/stores/pointsConfig.js` |
|
||||
| 新增 | `frontend/app/web-gold/src/components/common/PointsTag.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/agents/ChatDrawer.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/dh/Video.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/content-style/Benchmark.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/trends/Forecast.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/material/MaterialListNew.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/composables/useUpload.js` |
|
||||
@@ -251,7 +251,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
vo.setIsVideo(isVideo);
|
||||
vo.setIsImage(isImage);
|
||||
|
||||
// 视频文件:使用 OSS 截帧
|
||||
// 视频文件:使用 OSS 截帧作为封面
|
||||
if (isVideo) {
|
||||
// 使用 OSS 视频截帧作为封面(预签名时包含截帧参数,避免 403)
|
||||
// t_1000: 截取1秒处(避免开头黑屏/非关键帧)
|
||||
@@ -262,7 +262,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
file.getFileUrl(),
|
||||
PRESIGN_URL_EXPIRATION_SECONDS,
|
||||
"video/snapshot,t_1000,f_jpg,w_300");
|
||||
vo.setPreviewUrl(snapshotUrl);
|
||||
vo.setImgUrl(snapshotUrl);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
@@ -273,8 +273,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
: null;
|
||||
vo.setThumbnailUrl(thumbnailUrl);
|
||||
|
||||
// 图片预览URL:优先缩略图,否则原图
|
||||
vo.setPreviewUrl(isImage
|
||||
// 图片封面URL:优先缩略图,否则原图
|
||||
vo.setImgUrl(isImage
|
||||
? (thumbnailUrl != null ? thumbnailUrl : getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS))
|
||||
: null);
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ public class AppTikUserFileRespVO {
|
||||
@Schema(description = "文件访问URL", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileUrl;
|
||||
|
||||
@Schema(description = "预览URL(视频返回封面,图片返回缩略图)")
|
||||
private String previewUrl;
|
||||
@Schema(description = "封面图URL(视频返回截帧,图片返回缩略图)")
|
||||
private String imgUrl;
|
||||
|
||||
@Schema(description = "是否为视频文件", example = "true")
|
||||
private Boolean isVideo;
|
||||
|
||||
Reference in New Issue
Block a user