Files
sionrui/frontend/app/web-gold/src/views/kling/IdentifyFace.vue
2026-02-24 22:11:30 +08:00

826 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<FullWidthLayout :show-padding="false">
<div class="digital-human-page">
<!-- 左侧配置区 -->
<section class="config-panel">
<!-- 步骤1输入文案 -->
<div class="config-card">
<div class="step-indicator">1</div>
<h3 class="card-title">输入播文案</h3>
<a-textarea
v-model:value="ttsText"
:placeholder="textareaPlaceholder"
:rows="4"
:maxlength="maxTextLength"
:show-count="true"
class="text-input"
:bordered="false"
/>
<div class="input-meta">
<span>当前字数{{ ttsText?.length || 0 }}</span>
<span>预计音频时长<span class="meta-value">{{ audioDurationSec }}</span></span>
</div>
</div>
<!-- 步骤2语音设置 -->
<div class="config-card">
<div class="step-indicator">2</div>
<h3 class="card-title">语音设置</h3>
<div class="voice-settings">
<div class="setting-group">
<label class="setting-label">选择音色</label>
<VoiceSelector
:synth-text="ttsText"
:speech-rate="speechRate"
@select="handleVoiceSelect"
/>
</div>
<div class="setting-group">
<label class="setting-label">合成模型等级</label>
<div class="model-options">
<button
class="model-btn"
:class="{ 'model-btn--active': speechRate <= 1 }"
>
标准版 (1x积分)
</button>
<button
class="model-btn model-btn--pro"
:class="{ 'model-btn--active': speechRate > 1 }"
>
Pro 旗舰版 (3x积分)
<CrownFilled class="pro-icon" />
</button>
</div>
</div>
</div>
</div>
<!-- 步骤3上传视频 -->
<div class="config-card">
<div class="step-indicator">3</div>
<h3 class="card-title">上传/选择视频素材</h3>
<div class="video-options">
<!-- 上传新视频 -->
<div
class="video-option-card"
:class="{ 'video-option-card--selected': videoState.videoSource === 'upload' }"
@click="handleSelectUpload"
>
<div class="video-option-icon">
<CloudUploadOutlined />
</div>
<div class="video-option-content">
<h4>点击上传新视频</h4>
<p>支持 MP4MOV ( >3)</p>
</div>
</div>
<!-- 从素材库选择 -->
<div
class="video-option-card"
:class="{ 'video-option-card--selected': videoState.videoSource === 'select' }"
@click="handleSelectFromLibrary"
>
<div class="video-option-icon">
<PictureOutlined />
</div>
<div class="video-option-content">
<h4>从素材库选择</h4>
<p>选择已上传的视频</p>
</div>
</div>
</div>
<!-- 已选择视频预览 -->
<div v-if="videoState.selectedVideo" class="selected-video">
<div class="video-preview-thumb">
<img
:src="getVideoPreviewUrl(videoState.selectedVideo)"
:alt="videoState.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>
<button class="change-video-btn" @click="replaceVideo">更换</button>
</div>
<!-- 上传区域 -->
<div
v-if="videoState.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" />
<div v-if="!videoState.uploadedVideo" class="upload-placeholder">
<CloudUploadOutlined class="upload-icon" />
<span class="upload-text">点击上传新视频</span>
<span class="upload-hint">支持 MP4MOV ( >3)</span>
<button class="select-file-btn" @click="triggerFileSelect">选择文件</button>
</div>
<div v-else class="upload-preview">
<video :src="videoState.uploadedVideo" controls class="preview-video-player"></video>
<p class="upload-filename">{{ videoState.videoFile?.name }}</p>
<button class="change-video-btn" @click="replaceVideo">更换</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 class="action-section">
<a-button
v-if="!isPipelineCompleted"
type="primary"
size="large"
:disabled="!canGenerate"
:loading="isPipelineBusy"
block
@click="generateAudio"
class="action-btn"
>
{{ isPipelineBusy ? pipelineStateLabel + '...' : '生成数字人视频' }}
</a-button>
<div v-else class="completed-tip">
<span>任务已提交成功</span>
<a-button @click="resetPipeline" class="reset-btn">重新生成</a-button>
</div>
</div>
</section>
<!-- 右侧预览区 -->
<aside class="preview-panel">
<div class="preview-card">
<h3 class="preview-title">合成预览</h3>
<div class="preview-screen">
<ResultPanel @videoLoaded="handleVideoLoaded" />
</div>
<div class="preview-meta">
<div class="meta-row">
<span class="meta-label">预计消耗积分</span>
<span class="meta-value">150 积分</span>
</div>
<div class="meta-row">
<span class="meta-label">当前余额</span>
<span class="meta-value">{{ userStore.remainingPoints.toLocaleString() }} 积分</span>
</div>
</div>
</div>
</aside>
</div>
<!-- 视频选择器弹窗 -->
<VideoSelector v-model:open="videoState.selectorVisible" @select="handleVideoSelect" />
</FullWidthLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { CloudUploadOutlined, CrownFilled } 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'
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,
audioDurationSec,
// Pipeline 状态(单一状态源)
pipelineState,
pipelineStateLabel,
isPipelineBusy,
isPipelineReady,
isPipelineFailed,
isPipelineCompleted,
pipelineProgress,
pipelineError,
retryPipeline,
resetPipeline,
// 事件处理方法
handleVoiceSelect,
handleDrop,
handleSelectUpload,
handleSelectFromLibrary,
handleVideoSelect,
handleVideoLoaded,
replaceVideo,
// UI 辅助方法
formatDuration,
} = controller
// ==================== 生命周期 ====================
// 引用 fileInput 用于手动触发点击
const fileInput = ref<HTMLInputElement | null>(null)
// 触发文件选择
const triggerFileSelect = () => {
fileInput.value?.click()
}
// 覆盖 controller 中的方法,使用 ref
const handleFileSelectWrapper = (e: Event) => {
controller.handleFileSelect(e)
}
// ==================== 生命周期 ====================
import { onMounted } from 'vue'
onMounted(async () => {
await voiceStore.refresh()
})
</script>
<style scoped lang="less">
.digital-human-page {
min-height: 100vh;
background: #F8FAFC;
display: flex;
flex-direction: row;
gap: 24px;
padding: 24px;
}
// 布局容器
.config-panel {
flex: 1;
padding:0 20px;
max-width: 100%;
}
.preview-panel {
width: 400px;
background: #fff;
border-left: 1px solid #E2E8F0;
padding: 20px;
display: flex;
flex-direction: column;
flex-shrink: 0;
@media (max-width: 1200px) {
width: 100%;
border-left: none;
border-top: 1px solid #E2E8F0;
order: -1;
}
}
// 配置卡片
.config-card {
position: relative;
background: #fff;
border: 1px solid #E2E8F0;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
overflow: hidden;
}
.step-indicator {
position: absolute;
left: 0;
top: 0;
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;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1E293B;
margin: 0 0 12px 12px;
padding-left: 44px;
}
// 文案输入
.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;
}
:deep(.ant-input-textarea-show-count) {
bottom: 8px;
}
}
}
.input-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #94A3B8;
margin-top: 12px;
}
.meta-value {
font-weight: 600;
color: #1E293B;
}
// 语音设置
.voice-settings {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.setting-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-label {
font-size: 13px;
font-weight: 500;
color: #64748B;
}
.model-options {
display: flex;
background: #F1F5F9;
border-radius: 6px;
padding: 2px;
gap: 2px;
}
.model-btn {
flex: 1;
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #64748B;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
&--active {
background: #fff;
color: #22C55E;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
.pro-icon {
font-size: 12px;
color: #EAB308;
}
// 视频选项
.video-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
}
.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;
}
}
.video-option-icon {
font-size: 20px;
color: #94A3B8;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #fff;
border-radius: 6px;
flex-shrink: 0;
}
.video-option-content {
flex: 1;
}
.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;
gap: 10px;
padding: 10px;
background: #F1F5F9;
border-radius: 8px;
}
.video-preview-thumb {
width: 70px;
height: 40px;
border-radius: 6px;
overflow: hidden;
background: #1E293B;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.video-preview-info {
flex: 1;
min-width: 0;
}
.video-name {
font-size: 13px;
font-weight: 500;
color: #1E293B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-meta {
font-size: 11px;
color: #94A3B8;
}
.change-video-btn {
padding: 4px 10px;
font-size: 11px;
color: #64748B;
border: none;
background: transparent;
cursor: pointer;
text-decoration: underline;
flex-shrink: 0;
&:hover {
color: #3B82F6;
}
}
// 上传区域
.upload-zone {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #E2E8F0;
border-radius: 10px;
background: #F8FAFC;
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;
}
.upload-icon {
font-size: 28px;
color: #94A3B8;
}
.upload-text {
font-size: 13px;
font-weight: 500;
color: #1E293B;
}
.upload-hint {
font-size: 11px;
color: #94A3B8;
}
.select-file-btn {
margin-top: 12px;
padding: 6px 16px;
font-size: 12px;
font-weight: 500;
color: #3B82F6;
border: none;
background: transparent;
cursor: pointer;
border: 1px solid #3B82F6;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.1);
}
}
.upload-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 12px;
}
.preview-video-player {
width: 100%;
max-height: 140px;
border-radius: 8px;
}
.upload-filename {
font-size: 12px;
color: #64748B;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 6px 10px;
background: #F1F5F9;
border-radius: 6px;
}
// 操作按钮区
.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;
}
}
}
.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;
top: 24px;
}
.preview-title {
font-size: 16px;
font-weight: 600;
color: #1E293B;
margin: 0 0 16px 0;
}
.preview-screen {
aspect-ratio: 9/16;
background: #1E293B;
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
&::after {
content: '预览画面不可用';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 13px;
}
:deep(.result-panel) {
height: 100%;
}
}
.preview-meta {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
background: #F8FAFC;
border-radius: 8px;
}
.meta-row {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.meta-label {
color: #64748B;
}
.meta-row .meta-value {
font-weight: 600;
color: #1E293B;
}
// 响应式
@media (max-width: 1024px) {
.digital-human-page {
flex-direction: column;
}
.config-panel {
max-width: 100%;
padding: 16px;
}
.preview-panel {
width: 100%;
border-left: none;
border-top: 1px solid #E2E8F0;
order: -1;
padding: 16px;
}
.preview-card {
position: static;
}
.preview-screen {
min-height: 200px;
}
}
</style>