This commit is contained in:
2026-03-05 21:01:34 +08:00
parent 27d1c53b49
commit c07a61c424
21 changed files with 3061 additions and 1465 deletions

View File

@@ -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)
}

View File

@@ -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 - 请求参数

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>支持 MP4MOV 格式</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">支持 MP4MOV 格式 >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>

View File

@@ -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>

View File

@@ -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,
}
})

View File

@@ -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
}

View 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]
}

View File

@@ -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"

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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 增加的存储量GBBigDecimal转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` |

View File

@@ -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);

View File

@@ -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;