feat: 优化

This commit is contained in:
2026-02-26 18:52:09 +08:00
parent c2e4fde218
commit b76e3ff47d
17 changed files with 1027 additions and 1630 deletions

View File

@@ -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">支持 MP4MOV ( >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;
}