feat: 优化
This commit is contained in:
@@ -10,17 +10,17 @@
|
||||
<h3 class="card-title">输入播文案</h3>
|
||||
|
||||
<a-textarea
|
||||
v-model:value="ttsText"
|
||||
:placeholder="textareaPlaceholder"
|
||||
v-model:value="store.text"
|
||||
:placeholder="placeholder"
|
||||
:rows="4"
|
||||
:maxlength="maxTextLength"
|
||||
:maxlength="4000"
|
||||
:show-count="true"
|
||||
class="text-input"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<div class="input-meta">
|
||||
<span>当前字数:{{ ttsText?.length || 0 }}字</span>
|
||||
<span>当前字数:{{ store.text?.length || 0 }}字</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">选择音色</label>
|
||||
<VoiceSelector
|
||||
:synth-text="ttsText"
|
||||
:speech-rate="speechRate"
|
||||
@select="handleVoiceSelect"
|
||||
:synth-text="store.text"
|
||||
:speech-rate="store.speechRate"
|
||||
@select="store.setVoice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
<div class="model-options">
|
||||
<button
|
||||
class="model-btn"
|
||||
:class="{ 'model-btn--active': speechRate <= 1 }"
|
||||
:class="{ 'model-btn--active': store.speechRate <= 1 }"
|
||||
>
|
||||
标准版 (1x积分)
|
||||
</button>
|
||||
<button
|
||||
class="model-btn model-btn--pro"
|
||||
:class="{ 'model-btn--active': speechRate > 1 }"
|
||||
:class="{ 'model-btn--active': store.speechRate > 1 }"
|
||||
>
|
||||
Pro 旗舰版 (3x积分)
|
||||
<CrownFilled class="pro-icon" />
|
||||
@@ -69,8 +69,8 @@
|
||||
<!-- 上传新视频 -->
|
||||
<div
|
||||
class="video-option-card"
|
||||
:class="{ 'video-option-card--selected': videoState.videoSource === 'upload' }"
|
||||
@click="handleSelectUpload"
|
||||
:class="{ 'video-option-card--selected': store.videoSource === 'upload' }"
|
||||
@click="store.selectUploadMode"
|
||||
>
|
||||
<div class="video-option-icon">
|
||||
<CloudUploadOutlined />
|
||||
@@ -84,8 +84,8 @@
|
||||
<!-- 从素材库选择 -->
|
||||
<div
|
||||
class="video-option-card"
|
||||
:class="{ 'video-option-card--selected': videoState.videoSource === 'select' }"
|
||||
@click="handleSelectFromLibrary"
|
||||
:class="{ 'video-option-card--selected': store.videoSource === 'select' }"
|
||||
@click="store.selectLibraryMode"
|
||||
>
|
||||
<div class="video-option-icon">
|
||||
<PictureOutlined />
|
||||
@@ -98,32 +98,32 @@
|
||||
</div>
|
||||
|
||||
<!-- 已选择视频预览 -->
|
||||
<div v-if="videoState.selectedVideo" class="selected-video">
|
||||
<div v-if="store.selectedVideo" class="selected-video">
|
||||
<div class="video-preview-thumb">
|
||||
<img
|
||||
:src="getVideoPreviewUrl(videoState.selectedVideo)"
|
||||
:alt="videoState.selectedVideo.fileName"
|
||||
:src="getVideoPreviewUrl(store.selectedVideo)"
|
||||
:alt="store.selectedVideo.fileName"
|
||||
/>
|
||||
</div>
|
||||
<div class="video-preview-info">
|
||||
<div class="video-name">{{ videoState.selectedVideo.fileName }}</div>
|
||||
<div class="video-meta">{{ formatDuration(videoState.selectedVideo.duration) }}</div>
|
||||
<div class="video-name">{{ store.selectedVideo.fileName }}</div>
|
||||
<div class="video-meta">{{ formatDuration(store.selectedVideo.duration) }}</div>
|
||||
</div>
|
||||
<button class="change-video-btn" @click="replaceVideo">更换</button>
|
||||
<button class="change-video-btn" @click="store.reset">更换</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div
|
||||
v-if="videoState.videoSource === 'upload'"
|
||||
v-if="store.videoSource === 'upload'"
|
||||
class="upload-zone"
|
||||
:class="{ 'upload-zone--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="handleFileSelectWrapper" />
|
||||
<input ref="fileInput" type="file" accept=".mp4,.mov" class="file-input" @change="handleFileSelect" />
|
||||
|
||||
<div v-if="!videoState.uploadedVideo" class="upload-placeholder">
|
||||
<div v-if="!store.videoPreviewUrl" class="upload-placeholder">
|
||||
<CloudUploadOutlined class="upload-icon" />
|
||||
<span class="upload-text">点击上传新视频</span>
|
||||
<span class="upload-hint">支持 MP4、MOV (需 >3秒)</span>
|
||||
@@ -132,52 +132,51 @@
|
||||
|
||||
<div v-else class="upload-preview">
|
||||
<video
|
||||
:src="videoState.uploadedVideo"
|
||||
:src="store.videoPreviewUrl"
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="preview-video-player"
|
||||
@error="handleVideoError"
|
||||
></video>
|
||||
<p class="upload-filename">{{ videoState.videoFile?.name }}</p>
|
||||
<button class="change-video-btn" @click="replaceVideo">更换</button>
|
||||
<p class="upload-filename">{{ store.videoFile?.name }}</p>
|
||||
<button class="change-video-btn" @click="store.reset">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline 进度 -->
|
||||
<PipelineProgress
|
||||
v-if="isPipelineBusy || isPipelineReady || isPipelineFailed || isPipelineCompleted"
|
||||
:state="pipelineState"
|
||||
:progress="pipelineProgress"
|
||||
:is-busy="isPipelineBusy"
|
||||
:is-ready="isPipelineReady"
|
||||
:is-failed="isPipelineFailed"
|
||||
:is-completed="isPipelineCompleted"
|
||||
:error="pipelineError"
|
||||
@retry="retryPipeline"
|
||||
@reset="resetPipeline"
|
||||
/>
|
||||
<!-- 进度显示 -->
|
||||
<div v-if="store.isBusy || store.isFailed || store.isDone" class="progress-card">
|
||||
<div class="progress-header">
|
||||
<span class="progress-label">{{ store.stepLabel }}</span>
|
||||
<span class="progress-percent">{{ store.progress }}%</span>
|
||||
</div>
|
||||
<a-progress :percent="store.progress" :status="progressStatus" :show-info="false" />
|
||||
|
||||
<div v-if="store.isFailed" class="error-actions">
|
||||
<span class="error-text">{{ store.error }}</span>
|
||||
<a-button size="small" @click="store.retry">重试</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.isDone" class="success-actions">
|
||||
<span class="success-text">任务已提交成功</span>
|
||||
<a-button size="small" @click="store.reset">重新生成</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-section">
|
||||
<a-button
|
||||
v-if="!isPipelineCompleted"
|
||||
v-if="!store.isDone"
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!canGenerate"
|
||||
:loading="isPipelineBusy"
|
||||
:disabled="!store.canGenerate"
|
||||
:loading="store.isBusy"
|
||||
block
|
||||
@click="generateAudio"
|
||||
@click="store.generate"
|
||||
class="action-btn"
|
||||
>
|
||||
{{ isPipelineBusy ? pipelineStateLabel + '...' : '生成数字人视频' }}
|
||||
{{ store.isBusy ? store.stepLabel + '...' : '生成数字人视频' }}
|
||||
</a-button>
|
||||
|
||||
<div v-else class="completed-tip">
|
||||
<span>任务已提交成功</span>
|
||||
<a-button @click="resetPipeline" class="reset-btn">重新生成</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@@ -207,90 +206,85 @@
|
||||
</div>
|
||||
|
||||
<!-- 视频选择器弹窗 -->
|
||||
<VideoSelector v-model:open="videoState.selectorVisible" @select="handleVideoSelect" />
|
||||
<VideoSelector
|
||||
v-model:open="store.videoSelectorVisible"
|
||||
@select="store.selectVideo"
|
||||
/>
|
||||
</FullWidthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { CloudUploadOutlined, CrownFilled } from '@ant-design/icons-vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { CloudUploadOutlined, CrownFilled, PictureOutlined } from '@ant-design/icons-vue'
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import VideoSelector from '@/components/VideoSelector.vue'
|
||||
import VoiceSelector from '@/components/VoiceSelector.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
|
||||
import PipelineProgress from '@/components/PipelineProgress.vue'
|
||||
|
||||
// Controller Hook
|
||||
import { useIdentifyFaceController } from './hooks/useIdentifyFaceController'
|
||||
import { useDigitalHumanStore } from './stores/useDigitalHumanStore'
|
||||
|
||||
// ==================== Store ====================
|
||||
const store = useDigitalHumanStore()
|
||||
const voiceStore = useVoiceCopyStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ==================== 本地状态 ====================
|
||||
const dragOver = ref(false)
|
||||
|
||||
// ==================== 初始化 Controller ====================
|
||||
|
||||
// Controller 内部直接创建和管理两个子 Hook
|
||||
const controller = useIdentifyFaceController()
|
||||
|
||||
// 解构 controller 以简化模板调用
|
||||
const {
|
||||
// 语音生成相关
|
||||
ttsText,
|
||||
speechRate,
|
||||
generateAudio,
|
||||
|
||||
// 数字人生成相关
|
||||
videoState,
|
||||
getVideoPreviewUrl,
|
||||
|
||||
// 计算属性
|
||||
canGenerate,
|
||||
maxTextLength,
|
||||
textareaPlaceholder,
|
||||
|
||||
// Pipeline 状态(单一状态源)
|
||||
pipelineState,
|
||||
pipelineStateLabel,
|
||||
isPipelineBusy,
|
||||
isPipelineReady,
|
||||
isPipelineFailed,
|
||||
isPipelineCompleted,
|
||||
pipelineProgress,
|
||||
pipelineError,
|
||||
retryPipeline,
|
||||
resetPipeline,
|
||||
|
||||
// 事件处理方法
|
||||
handleVoiceSelect,
|
||||
handleDrop,
|
||||
handleSelectUpload,
|
||||
handleSelectFromLibrary,
|
||||
handleVideoSelect,
|
||||
handleVideoLoaded,
|
||||
handleVideoError,
|
||||
replaceVideo,
|
||||
|
||||
// UI 辅助方法
|
||||
formatDuration,
|
||||
} = controller
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
// 引用 fileInput 用于手动触发点击
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileSelect = () => {
|
||||
// ==================== 计算属性 ====================
|
||||
const placeholder = computed(() => {
|
||||
if (store.faceDurationMs > 0) {
|
||||
const maxChars = Math.floor(store.faceDurationMs / 1000 * 4)
|
||||
return `请输入文案,建议不超过${maxChars}字以确保与视频匹配`
|
||||
}
|
||||
return '请输入你想让角色说话的内容'
|
||||
})
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
if (store.isFailed) return 'exception'
|
||||
if (store.isDone) return 'success'
|
||||
return 'active'
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
function triggerFileSelect() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
// 覆盖 controller 中的方法,使用 ref
|
||||
const handleFileSelectWrapper = (e: Event) => {
|
||||
controller.handleFileSelect(e)
|
||||
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 handleVideoLoaded(_url: string) {
|
||||
// 可用于更新预览
|
||||
}
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
function getVideoPreviewUrl(video: any): string {
|
||||
if (video.coverBase64) {
|
||||
return video.coverBase64.startsWith('data:')
|
||||
? video.coverBase64
|
||||
: `data:image/jpeg;base64,${video.coverBase64}`
|
||||
}
|
||||
return video.previewUrl || video.coverUrl || ''
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
voiceStore.refresh(),
|
||||
@@ -309,10 +303,9 @@ onMounted(async () => {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
// 布局容器
|
||||
.config-panel {
|
||||
flex: 1;
|
||||
padding:0 20px;
|
||||
padding: 0 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -333,7 +326,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 配置卡片
|
||||
.config-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
@@ -369,7 +361,6 @@ onMounted(async () => {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
// 文案输入
|
||||
.text-input {
|
||||
width: 100%;
|
||||
|
||||
@@ -384,10 +375,6 @@ onMounted(async () => {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px #E2E8F0;
|
||||
}
|
||||
|
||||
:deep(.ant-input-textarea-show-count) {
|
||||
bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,12 +386,6 @@ onMounted(async () => {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
// 语音设置
|
||||
.voice-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -468,7 +449,6 @@ onMounted(async () => {
|
||||
color: #EAB308;
|
||||
}
|
||||
|
||||
// 视频选项
|
||||
.video-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -518,22 +498,21 @@ onMounted(async () => {
|
||||
|
||||
.video-option-content {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1E293B;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 11px;
|
||||
color: #94A3B8;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.video-option-content h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1E293B;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.video-option-content p {
|
||||
font-size: 11px;
|
||||
color: #94A3B8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 已选择视频
|
||||
.selected-video {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -593,7 +572,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传区域
|
||||
.upload-zone {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
@@ -643,10 +621,9 @@ onMounted(async () => {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #3B82F6;
|
||||
border: none;
|
||||
border: 1px solid #3B82F6;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border: 1px solid #3B82F6;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@@ -681,7 +658,61 @@ onMounted(async () => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// 操作按钮区
|
||||
// 进度卡片
|
||||
.progress-card {
|
||||
background: #fff;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 14px;
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #FEE2E2;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #86EFAC;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
.action-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
@@ -708,35 +739,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.completed-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #F0FDF4;
|
||||
border: 1px solid #86EFAC;
|
||||
border-radius: 8px;
|
||||
color: #166534;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
.reset-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: #3B82F6;
|
||||
border: 1px solid #3B82F6;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览面板
|
||||
.preview-card {
|
||||
position: sticky;
|
||||
@@ -797,7 +799,7 @@ onMounted(async () => {
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.meta-row .meta-value {
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user