5480 lines
180 KiB
Vue
5480 lines
180 KiB
Vue
<template>
|
||
<div class="storyboard-video-create-page">
|
||
<!-- 顶部导航栏 -->
|
||
<header class="top-header">
|
||
<div class="header-left">
|
||
<button class="back-btn" @click="goBack">
|
||
← {{ t('common.home') }}
|
||
</button>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="points-display">
|
||
<div class="points-icon">
|
||
<el-icon><Star /></el-icon>
|
||
</div>
|
||
<span class="points-number">{{ userStore.availablePoints }}</span>
|
||
</div>
|
||
<LanguageSwitcher />
|
||
<div class="user-avatar" @click="toggleUserMenu" ref="userAvatarRef">
|
||
<img src="/images/backgrounds/avatar-default.svg" :alt="t('video.storyboard.userAvatar')" />
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 分镜视频创作逻辑改了3版,有很多逻辑用不上但是我保留了,谁知道什么时候就又用上了 -->
|
||
|
||
<!-- 用户菜单下拉 -->
|
||
<Teleport to="body">
|
||
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
|
||
<!-- 管理员功能 -->
|
||
<template v-if="userStore.isAdmin">
|
||
<div class="menu-item" @click.stop="goToDashboard">
|
||
<el-icon><User /></el-icon>
|
||
<span>{{ t('profile.dashboard') }}</span>
|
||
</div>
|
||
<div class="menu-item" @click.stop="goToOrders">
|
||
<el-icon><Document /></el-icon>
|
||
<span>{{ t('profile.orderManagement') }}</span>
|
||
</div>
|
||
<div class="menu-item" @click.stop="goToMembers">
|
||
<el-icon><User /></el-icon>
|
||
<span>{{ t('profile.memberManagement') }}</span>
|
||
</div>
|
||
<div class="menu-item" @click.stop="goToSystemSettings">
|
||
<el-icon><Setting /></el-icon>
|
||
<span>{{ t('profile.systemSettings') }}</span>
|
||
</div>
|
||
<div class="menu-item" @click.stop="goToErrorStats">
|
||
<el-icon><Warning /></el-icon>
|
||
<span>错误统计</span>
|
||
</div>
|
||
<div class="menu-item" @click.stop="goToApiManagement">
|
||
<el-icon><Document /></el-icon>
|
||
<span>{{ t('nav.apiManagement') }}</span>
|
||
</div>
|
||
<div class="menu-item" @click.stop="goToTaskRecord">
|
||
<el-icon><Document /></el-icon>
|
||
<span>{{ t('nav.tasks') }}</span>
|
||
</div>
|
||
</template>
|
||
<!-- 修改密码(所有登录用户可见) -->
|
||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
|
||
<el-icon><Lock /></el-icon>
|
||
<span>{{ t('profile.changePassword') }}</span>
|
||
</div>
|
||
<!-- 退出登录 -->
|
||
<div class="menu-item" @click.stop="logout">
|
||
<el-icon><SwitchButton /></el-icon>
|
||
<span>{{ t('common.logout') }}</span>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- 主内容区域 -->
|
||
<div class="main-content">
|
||
<!-- 左侧设置面板 -->
|
||
<div class="left-panel">
|
||
<!-- 左侧可滚动内容区域 -->
|
||
<div class="left-panel-content">
|
||
<!-- 创作模式标签 -->
|
||
<div class="creation-tabs">
|
||
<div class="tab" @click="goToTextToVideo">{{ t('home.textToVideo') }}</div>
|
||
<div class="tab" @click="goToImageToVideo">{{ t('home.imageToVideo') }}</div>
|
||
<div class="tab active">{{ t('home.storyboardVideo') }}</div>
|
||
</div>
|
||
|
||
<!-- 分镜步骤标签 - SVG版本 -->
|
||
<div class="storyboard-steps-svg">
|
||
<svg width="244" height="43" viewBox="0 0 244 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- 左侧底部指示条 - 生成分镜图激活时显示 -->
|
||
<rect v-if="currentStep === 'generate'" x="80" y="41" width="2" height="80" rx="1" transform="rotate(90 80 41)" fill="white"/>
|
||
<!-- 右侧底部指示条 - 生成分镜视频激活时显示 -->
|
||
<rect v-if="currentStep === 'video'" x="244" y="41" width="2" height="80" rx="1" transform="rotate(90 244 41)" fill="white"/>
|
||
|
||
<!-- 左侧点击区域 - 生成分镜图 -->
|
||
<rect
|
||
x="0" y="0" width="122" height="43"
|
||
fill="transparent"
|
||
style="cursor: pointer;"
|
||
@click="switchToGenerateStep"
|
||
/>
|
||
<!-- 右侧点击区域 - 生成分镜视频 -->
|
||
<rect
|
||
x="122" y="0" width="122" height="43"
|
||
fill="transparent"
|
||
style="cursor: pointer;"
|
||
@click="switchToVideoStep"
|
||
/>
|
||
|
||
<!-- STEP1 生成分镜图 文字 -->
|
||
<path :opacity="currentStep === 'generate' ? '1' : '0.4'" d="M4.048 22.232C3.424 23.64 2.624 24.84 1.648 25.8L0.544 24.408C1.984 22.904 2.944 20.888 3.44 18.344L5.152 18.664C5.008 19.352 4.848 20.008 4.656 20.616H7.44V17.992H9.12V20.616H14.192V22.232H9.12V25.368H13.888V26.984H9.12V30.472H15.232V32.136H0.992V30.472H7.44V26.984H2.928V25.368H7.44V22.232H4.048ZM28.144 17.72C28.864 18.264 29.472 18.824 29.984 19.384L29.248 20.136H31.152V21.72H26.448C26.576 23.576 26.768 25.016 27.024 26.008C27.072 26.232 27.136 26.44 27.2 26.664C27.84 25.544 28.368 24.248 28.784 22.808L30.304 23.464C29.68 25.496 28.88 27.224 27.904 28.648C28.048 28.968 28.208 29.256 28.368 29.496C28.816 30.184 29.168 30.536 29.408 30.536C29.568 30.536 29.76 29.768 29.968 28.232L31.472 29.048C31.056 31.304 30.464 32.44 29.664 32.44C28.96 32.44 28.224 31.96 27.44 31.016C27.216 30.728 26.992 30.408 26.8 30.056C25.744 31.208 24.528 32.088 23.152 32.696L22.192 31.288C23.696 30.616 24.976 29.64 26.032 28.36C25.808 27.8 25.616 27.192 25.44 26.536C25.088 25.256 24.864 23.64 24.736 21.72H19.744V23.944H23.664C23.616 26.92 23.456 28.776 23.184 29.512C22.928 30.184 22.368 30.52 21.504 30.536C21.12 30.536 20.688 30.504 20.208 30.456L19.696 28.936C20.336 28.984 20.864 29.016 21.312 29.016C21.568 29 21.744 28.84 21.824 28.52C21.92 28.152 21.968 27.144 22 25.512H19.744C19.664 28.488 19.008 30.872 17.776 32.68L16.512 31.544C17.504 30.088 18.016 28.056 18.064 25.464V20.136H24.656C24.624 19.4 24.624 18.616 24.624 17.816H26.32C26.32 18.632 26.336 19.4 26.368 20.136H28.624C28.224 19.688 27.712 19.208 27.088 18.728L28.144 17.72ZM37.52 18.136L38.96 19.016C38.224 20.328 37.472 21.416 36.72 22.248C35.984 23.08 34.928 23.992 33.52 25L32.608 23.512C33.6 22.968 34.56 22.2 35.456 21.192C36.176 20.36 36.864 19.352 37.52 18.136ZM37.952 25.656H35.072V24.04H44.8C44.736 26.12 44.64 27.784 44.48 29.064C44.32 30.344 44.064 31.224 43.68 31.736C43.312 32.216 42.72 32.488 41.904 32.536H38.832L38.384 30.888C39.328 30.92 40.224 30.952 41.056 30.952C41.84 30.952 42.352 30.616 42.608 29.976C42.864 29.304 43.04 27.864 43.104 25.656H39.648C39.28 27.288 38.784 28.584 38.144 29.56C37.312 30.728 36.08 31.784 34.448 32.712L33.36 31.32C34.8 30.552 35.888 29.704 36.624 28.776C37.184 27.976 37.632 26.936 37.952 25.656ZM42.528 17.944C43.392 20.328 45.008 22.216 47.376 23.592L46.24 24.92C43.776 23.336 42.048 21.288 41.072 18.76L42.528 17.944ZM54.896 18.936H57.776C57.632 18.6 57.488 18.28 57.344 17.992L59.024 17.704C59.152 18.072 59.264 18.488 59.392 18.936H62.608V20.312H61.424C61.312 20.76 61.184 21.176 61.024 21.592H63.12V22.984H54.416V21.592H56.544C56.384 21.128 56.24 20.696 56.096 20.312H54.896V18.936ZM59.44 21.592C59.616 21.176 59.76 20.76 59.888 20.312H57.68C57.792 20.712 57.904 21.144 58.032 21.592H59.44ZM55.216 23.592H62.224V28.968H60.624V30.552C60.624 30.936 60.8 31.128 61.168 31.128H61.52C61.712 31.128 61.872 31.048 61.968 30.92C62.08 30.76 62.16 30.264 62.192 29.464L63.536 29.88C63.408 31.16 63.232 31.912 62.976 32.152C62.704 32.376 62.304 32.488 61.776 32.488H60.736C59.616 32.488 59.056 31.944 59.056 30.888V28.968H58.112C57.92 29.848 57.584 30.584 57.12 31.16C56.576 31.832 55.6 32.376 54.208 32.776L53.632 31.384C54.656 31.112 55.408 30.728 55.872 30.248C56.176 29.896 56.4 29.48 56.56 28.968H55.216V23.592ZM60.72 27.656V26.872H56.736V27.656H60.72ZM56.736 25.672H60.72V24.888H56.736V25.672ZM50.352 17.832L51.952 18.184C51.84 18.632 51.728 19.08 51.616 19.496H54.032V21.08H51.12C50.912 21.672 50.688 22.232 50.448 22.744H53.904V24.232H52.416V25.736H54.272V27.272H52.416V30.312C52.848 30.088 53.28 29.816 53.712 29.496L54 30.92C53.104 31.48 52.08 31.944 50.928 32.296L50.288 30.936C50.624 30.808 50.8 30.584 50.8 30.296V27.272H48.976V25.736H50.8V24.232H50.144V23.368C50.032 23.56 49.936 23.752 49.84 23.928L48.464 23.064C49.36 21.432 49.984 19.688 50.352 17.832ZM70.208 20.04L71.696 20.232C71.6 20.456 71.504 20.68 71.392 20.888H75.472V21.992C75.088 22.744 74.48 23.416 73.68 24.008C74.656 24.424 75.744 24.76 76.96 25.032L76.096 26.28C74.64 25.896 73.376 25.416 72.304 24.84C71.104 25.448 69.632 25.944 67.872 26.328L67.152 25C68.64 24.76 69.904 24.44 70.96 24.024C70.512 23.704 70.096 23.368 69.744 23.016C69.312 23.4 68.832 23.752 68.32 24.072L67.408 22.856C68.784 22.088 69.728 21.144 70.208 20.04ZM72.336 23.352C72.944 22.968 73.44 22.536 73.808 22.072H70.624C70.608 22.088 70.592 22.104 70.592 22.12C71.104 22.568 71.68 22.984 72.336 23.352ZM69.28 27.672C71.376 28.04 73.392 28.552 75.328 29.224L74.784 30.584C72.8 29.864 70.752 29.304 68.624 28.904L69.28 27.672ZM70.88 25.528C72.176 25.896 73.36 26.328 74.432 26.84L73.664 28.024C72.512 27.416 71.344 26.952 70.144 26.616L70.88 25.528ZM78.752 18.408V32.664H77.12V32.136H66.88V32.664H65.248V18.408H78.752ZM66.88 30.664H77.12V19.88H66.88V30.664Z" fill="white"/>
|
||
|
||
<!-- STEP1 小字 -->
|
||
<path :opacity="currentStep === 'generate' ? '0.5' : '0.1'" d="M7.07094 21.7786C6.97958 20.2819 6.87418 19.0837 6.75472 18.1843C7.54175 18.5356 8.24095 18.7816 8.85231 18.9221C9.4707 19.0556 10.0118 19.1224 10.4756 19.1224C11.0869 19.1224 11.5718 19.0346 11.9302 18.8589C12.2956 18.6762 12.4783 18.4091 12.4783 18.0578C12.4783 17.9383 12.4607 17.8259 12.4256 17.7205C12.3905 17.6081 12.3237 17.4851 12.2253 17.3516C12.1269 17.211 11.9864 17.0599 11.8037 16.8983L9.82205 15.1696C9.35826 14.7761 9.03501 14.4915 8.85231 14.3158C8.47987 13.9575 8.18825 13.6237 7.97743 13.3145C7.77365 13.0053 7.62256 12.6926 7.52418 12.3764C7.43283 12.0531 7.38716 11.7123 7.38716 11.3539C7.38716 10.9253 7.46094 10.5072 7.60851 10.0996C7.76311 9.68498 7.99149 9.29849 8.29365 8.94011C8.59582 8.58172 8.99637 8.27253 9.49529 8.01253C9.99422 7.75252 10.5283 7.57685 11.0975 7.48549C11.6667 7.38711 12.2534 7.33792 12.8578 7.33792C14.0453 7.33792 15.3383 7.47495 16.7367 7.74901C16.7578 8.23388 16.7824 8.6801 16.8105 9.08767C16.8386 9.48822 16.9054 10.1944 17.0108 11.2064C16.3643 10.9463 15.774 10.7672 15.24 10.6688C14.7059 10.5634 14.2456 10.5107 13.8591 10.5107C13.311 10.5107 12.8543 10.6126 12.4888 10.8163C12.1305 11.0201 11.9513 11.2731 11.9513 11.5753C11.9513 11.7158 11.9794 11.8528 12.0356 11.9864C12.0918 12.1199 12.1972 12.278 12.3518 12.4607C12.5064 12.6364 12.7102 12.8331 12.9632 13.051C13.2161 13.2618 13.6132 13.578 14.1543 13.9996L14.9132 14.6004C15.1381 14.7832 15.3805 15.0045 15.6405 15.2645C15.9005 15.5245 16.1254 15.781 16.3151 16.034C16.5048 16.287 16.6489 16.5188 16.7473 16.7297C16.8527 16.9405 16.93 17.1548 16.9792 17.3726C17.0284 17.5835 17.053 17.8118 17.053 18.0578C17.053 18.6902 16.9019 19.2946 16.5997 19.8708C16.2975 20.44 15.8689 20.9108 15.3137 21.2832C14.7586 21.6486 14.1472 21.9051 13.4797 22.0527C12.8191 22.2003 12.1199 22.2741 11.3821 22.2741C10.2437 22.2741 8.80663 22.1089 7.07094 21.7786ZM22.3338 22C22.6852 18.5848 22.896 14.8253 22.9663 10.7215L18.4759 10.8374L18.5286 9.66741C18.5568 9.042 18.5778 8.35685 18.5919 7.61198C20.5876 7.62603 22.9136 7.63306 25.5698 7.63306L28.1734 7.62252H30.7031L32.0734 7.61198L32.0102 8.63443C31.9821 9.18254 31.961 9.63579 31.9469 9.99417C31.9399 10.3526 31.9364 10.6231 31.9364 10.8058C30.4326 10.7355 29.1923 10.7004 28.2155 10.7004H27.4777C27.3723 11.9442 27.2915 13.0931 27.2352 14.1472C27.1579 15.658 27.0982 17.1267 27.0561 18.5532C27.0139 19.9727 26.9928 21.1216 26.9928 22C25.8404 21.9859 25.0955 21.9789 24.7582 21.9789C24.456 21.9789 23.6479 21.9859 22.3338 22ZM32.8218 22C32.9975 20.187 33.124 18.6691 33.2013 17.4464C33.2856 16.2237 33.3664 14.6004 33.4437 12.5766C33.528 10.5458 33.5702 9.20011 33.5702 8.53956L33.5596 7.61198C35.218 7.62603 37.2243 7.63306 39.5784 7.63306C41.7498 7.63306 43.6646 7.62603 45.323 7.61198L45.302 7.95982C45.2949 8.04415 45.2774 8.34983 45.2493 8.87686L45.186 10.0258C45.172 10.2717 45.1649 10.5212 45.1649 10.7742C43.8087 10.718 42.6703 10.6899 41.7498 10.6899C40.7378 10.6899 40.0527 10.6934 39.6943 10.7004C39.343 10.7004 38.7913 10.7145 38.0394 10.7426L37.9129 13.1564C38.7421 13.1775 39.7084 13.188 40.8116 13.188C41.2052 13.188 42.2522 13.1634 43.9528 13.1142C43.8825 14.0137 43.8157 15.0853 43.7525 16.3291C42.5368 16.3151 41.6022 16.308 40.9487 16.308C39.9789 16.308 38.8932 16.3326 37.6916 16.3818L37.5651 18.9432H38.7773C39.1357 18.9432 39.877 18.9327 41.0014 18.9116L43.1833 18.8589C43.5065 18.8519 44.0476 18.8237 44.8065 18.7746C44.7222 19.8146 44.652 20.8897 44.5957 22C42.8811 21.9859 41.0014 21.9789 38.9565 21.9789L34.3186 21.9895L32.8218 22ZM47.1044 22L47.3152 19.3016L47.5893 14.0313L47.7052 10.2999C47.7123 9.89931 47.7158 9.49876 47.7158 9.09822C47.7158 8.79605 47.7123 8.30064 47.7052 7.61198C48.6328 7.63306 49.3777 7.6436 49.9399 7.6436C50.4809 7.6436 51.2223 7.62955 52.1639 7.60144C53.1056 7.5663 53.9031 7.54874 54.5567 7.54874C55.4983 7.54874 56.194 7.60847 56.6437 7.72793C57.1005 7.84739 57.5116 8.03712 57.877 8.29712C58.2424 8.5501 58.5446 8.84875 58.7835 9.19308C59.0224 9.53741 59.2016 9.93093 59.3211 10.3736C59.4405 10.8093 59.5002 11.2555 59.5002 11.7123C59.5002 12.6539 59.2719 13.5148 58.8151 14.2948C58.3583 15.0748 57.7154 15.658 56.8862 16.0445C56.057 16.431 55.1259 16.6243 54.0929 16.6243C53.7134 16.6243 53.2742 16.5821 52.7753 16.4978C52.7191 16.1745 52.6137 15.6932 52.4591 15.0537C52.3045 14.4142 52.178 13.9399 52.0796 13.6307C52.3888 13.6939 52.7121 13.7256 53.0494 13.7256C53.7591 13.7256 54.2861 13.5569 54.6305 13.2196C54.9818 12.8753 55.1575 12.4572 55.1575 11.9653C55.1575 11.7264 55.1188 11.5155 55.0415 11.3328C54.9642 11.1431 54.8413 10.9885 54.6726 10.869C54.504 10.7496 54.3177 10.6723 54.114 10.6372C53.9102 10.602 53.6783 10.5845 53.4183 10.5845C53.1653 10.5845 52.9018 10.5915 52.6277 10.6055L51.7423 10.6688C51.651 12.025 51.5807 13.5323 51.5315 15.1907C51.4401 18.2124 51.3945 20.0254 51.3945 20.6297V22L49.2969 21.9789C49.0017 21.9789 48.2709 21.9859 47.1044 22ZM65.0763 22C65.1676 20.9108 65.2836 19.1505 65.4241 16.7191C65.5646 14.2877 65.6455 12.5485 65.6665 11.5015C65.4065 11.6631 65.1536 11.8247 64.9076 11.9864C64.6617 12.141 64.1276 12.5134 63.3054 13.1037C63.3476 12.5064 63.3862 11.8072 63.4214 11.0061C63.4565 10.198 63.4741 9.60417 63.4741 9.2247C63.8325 9.02092 64.2365 8.77848 64.6863 8.4974C65.143 8.21631 65.4206 8.04063 65.519 7.97036C65.6244 7.89307 65.779 7.7736 65.9828 7.61198C66.6082 7.62603 67.3285 7.63306 68.1436 7.63306C68.9166 7.63306 69.7071 7.62603 70.5152 7.61198L70.3044 9.96255L69.8512 17.7626L69.7458 20.6297V22C69.1485 21.9859 68.4212 21.9789 67.5639 21.9789C66.566 21.9789 65.7368 21.9859 65.0763 22Z" fill="white"/>
|
||
|
||
<!-- 箭头指示器 -->
|
||
<path opacity="0.4" d="M123.563 21.5009L116.963 14.9012L118.849 13.0156L127.334 21.5009L118.849 29.9861L116.963 28.1005L123.563 21.5009Z" fill="url(#paint0_linear_1600_2316)"/>
|
||
<path opacity="0.05" d="M116.6 21.5009L110 14.9012L111.886 13.0156L120.371 21.5009L111.886 29.9861L110 28.1005L116.6 21.5009Z" fill="url(#paint1_linear_1600_2316)"/>
|
||
|
||
<!-- STEP2 生成分镜视频 文字 -->
|
||
<path :opacity="currentStep === 'video' ? '1' : '0.4'" d="M176.048 22.232C175.424 23.64 174.624 24.84 173.648 25.8L172.544 24.408C173.984 22.904 174.944 20.888 175.44 18.344L177.152 18.664C177.008 19.352 176.848 20.008 176.656 20.616H179.44V17.992H181.12V20.616H186.192V22.232H181.12V25.368H185.888V26.984H181.12V30.472H187.232V32.136H172.992V30.472H179.44V26.984H174.928V25.368H179.44V22.232H176.048ZM200.144 17.72C200.864 18.264 201.472 18.824 201.984 19.384L201.248 20.136H203.152V21.72H198.448C198.576 23.576 198.768 25.016 199.024 26.008C199.072 26.232 199.136 26.44 199.2 26.664C199.84 25.544 200.368 24.248 200.784 22.808L202.304 23.464C201.68 25.496 200.88 27.224 199.904 28.648C200.048 28.968 200.208 29.256 200.368 29.496C200.816 30.184 201.168 30.536 201.408 30.536C201.568 30.536 201.76 29.768 201.968 28.232L203.472 29.048C203.056 31.304 202.464 32.44 201.664 32.44C200.96 32.44 200.224 31.96 199.44 31.016C199.216 30.728 198.992 30.408 198.8 30.056C197.744 31.208 196.528 32.088 195.152 32.696L194.192 31.288C195.696 30.616 196.976 29.64 198.032 28.36C197.808 27.8 197.616 27.192 197.44 26.536C197.088 25.256 196.864 23.64 196.736 21.72H191.744V23.944H195.664C195.616 26.92 195.456 28.776 195.184 29.512C194.928 30.184 194.368 30.52 193.504 30.536C193.12 30.536 192.688 30.504 192.208 30.456L191.696 28.936C192.336 28.984 192.864 29.016 193.312 29.016C193.568 29 193.744 28.84 193.824 28.52C193.92 28.152 193.968 27.144 194 25.512H191.744C191.664 28.488 191.008 30.872 189.776 32.68L188.512 31.544C189.504 30.088 190.016 28.056 190.064 25.464V20.136H196.656C196.624 19.4 196.624 18.616 196.624 17.816H198.32C198.32 18.632 198.336 19.4 198.368 20.136H200.624C200.224 19.688 199.712 19.208 199.088 18.728L200.144 17.72ZM206.8 26.6C206.288 27.112 205.728 27.608 205.136 28.12L204.608 26.36C206.592 24.696 207.936 23.064 208.656 21.48H204.976V19.88H207.152C206.912 19.288 206.656 18.712 206.368 18.168L207.952 17.72C208.272 18.504 208.544 19.224 208.736 19.88H210.336V21.288C209.936 22.408 209.328 23.528 208.512 24.632C209.328 25.144 210.08 25.704 210.784 26.344L209.84 27.784C209.36 27.256 208.896 26.808 208.448 26.424V32.664H206.8V26.6ZM218.32 18.36V27.432H216.624V19.96H212.768V27.432H211.104V18.36H218.32ZM217.776 32.408H216.176C215.168 32.408 214.672 31.864 214.672 30.776V28.6C214.512 29 214.352 29.368 214.176 29.704C213.392 31.048 212.144 32.072 210.416 32.808L209.504 31.336C211.184 30.6 212.32 29.672 212.912 28.568C213.488 27.368 213.808 25.816 213.872 23.928V20.92H215.472V23.928C215.424 25.224 215.28 26.376 215.04 27.384H216.304V30.424C216.304 30.728 216.416 30.888 216.672 30.888H217.472C217.616 30.888 217.728 30.808 217.808 30.648C217.888 30.456 217.952 29.944 217.984 29.08L219.536 29.576C219.424 30.952 219.232 31.784 218.976 32.04C218.704 32.28 218.304 32.408 217.776 32.408ZM223.776 17.896H225.408V19.592H227.296V21.16H225.408V22.472H227.504V24.104H220.624V22.472H221.392V19.224H222.992V22.472H223.776V17.896ZM226.4 25.176L227.856 25.512C227.152 29.128 225.088 31.496 221.648 32.632L220.688 31.112C222.384 30.6 223.712 29.816 224.672 28.744H223.68V24.504H225.28V27.96C225.808 27.16 226.176 26.232 226.4 25.176ZM221.792 25.224L223.232 25.8C222.88 27.304 222.4 28.472 221.808 29.288L220.576 28.216C221.12 27.448 221.536 26.456 221.792 25.224ZM227.648 18.36H235.296V19.928H232.256C232.224 20.376 232.176 20.792 232.128 21.208H234.912V29.048H233.28V22.744H229.648V29.048H228.032V21.208H230.4C230.448 20.792 230.496 20.36 230.528 19.928H227.648V18.36ZM232.848 28.92C233.888 29.784 234.768 30.648 235.504 31.496L234.256 32.744C233.664 31.88 232.816 30.968 231.728 29.992L232.848 28.92ZM230.608 23.48H232.24V26.28C232.176 28.04 231.792 29.432 231.104 30.488C230.432 31.448 229.36 32.2 227.856 32.728L226.944 31.272C228.352 30.76 229.296 30.152 229.792 29.416C230.272 28.616 230.544 27.56 230.608 26.28V23.48Z" fill="white"/>
|
||
|
||
<!-- STEP2 小字 -->
|
||
<path :opacity="currentStep === 'video' ? '0.5' : '0.1'" fill-rule="evenodd" clip-rule="evenodd" d="M171.071 21.7786C170.98 20.2819 170.874 19.0837 170.755 18.1843C171.542 18.5356 172.241 18.7816 172.852 18.9221C173.471 19.0556 174.012 19.1224 174.476 19.1224C175.087 19.1224 175.572 19.0346 175.93 18.8589C176.296 18.6762 176.478 18.4091 176.478 18.0578C176.478 17.9383 176.461 17.8259 176.426 17.7205C176.39 17.6081 176.324 17.4851 176.225 17.3516C176.127 17.211 175.986 17.0599 175.804 16.8983L173.822 15.1696C173.358 14.7761 173.035 14.4915 172.852 14.3158C172.48 13.9575 172.188 13.6237 171.977 13.3145C171.774 13.0053 171.623 12.6926 171.524 12.3764C171.433 12.0531 171.387 11.7123 171.387 11.3539C171.387 10.9253 171.461 10.5072 171.609 10.0996C171.763 9.68498 171.991 9.29849 172.294 8.94011C172.596 8.58172 172.996 8.27253 173.495 8.01253C173.994 7.75252 174.528 7.57685 175.097 7.48549C175.667 7.38711 176.253 7.33792 176.858 7.33792C178.045 7.33792 179.338 7.47495 180.737 7.74901C180.758 8.23388 180.782 8.6801 180.811 9.08767C180.839 9.48822 180.905 10.1944 181.011 11.2064C180.364 10.9463 179.774 10.7672 179.24 10.6688C178.706 10.5634 178.246 10.5107 177.859 10.5107C177.311 10.5107 176.854 10.6126 176.489 10.8163C176.13 11.0201 175.951 11.2731 175.951 11.5753C175.951 11.7158 175.979 11.8528 176.036 11.9864C176.092 12.1199 176.197 12.278 176.352 12.4607C176.506 12.6364 176.71 12.8331 176.963 13.051C177.216 13.2618 177.613 13.578 178.154 13.9996L178.913 14.6004C179.138 14.7832 179.38 15.0045 179.641 15.2645C179.901 15.5245 180.125 15.781 180.315 16.034C180.505 16.287 180.649 16.5188 180.747 16.7297C180.853 16.9405 180.93 17.1548 180.979 17.3726C181.028 17.5835 181.053 17.8118 181.053 18.0578C181.053 18.6902 180.902 19.2946 180.6 19.8708C180.298 20.44 179.869 20.9108 179.314 21.2832C178.759 21.6486 178.147 21.9051 177.48 22.0527C176.819 22.2003 176.12 22.2741 175.382 22.2741C174.244 22.2741 172.807 22.1089 171.071 21.7786ZM186.334 22C186.685 18.5848 186.896 14.8253 186.966 10.7215L182.476 10.8374L182.529 9.66741C182.557 9.042 182.578 8.35685 182.592 7.61198C184.588 7.62603 186.914 7.63306 189.57 7.63306L192.173 7.62252H194.703L196.073 7.61198L196.01 8.63443C195.982 9.18254 195.961 9.63579 195.947 9.99417C195.94 10.3526 195.936 10.6231 195.936 10.8058C194.433 10.7355 193.192 10.7004 192.216 10.7004H191.478C191.372 11.9442 191.291 13.0931 191.235 14.1472C191.158 15.658 191.098 17.1267 191.056 18.5532C191.014 19.9727 190.993 21.1216 190.993 22C189.84 21.9859 189.095 21.9789 188.758 21.9789C188.456 21.9789 187.648 21.9859 186.334 22ZM196.822 22C196.997 20.187 197.124 18.6691 197.201 17.4464C197.286 16.2237 197.366 14.6004 197.444 12.5766C197.528 10.5458 197.57 9.20011 197.57 8.53956L197.56 7.61198C199.218 7.62603 201.224 7.63306 203.578 7.63306C205.75 7.63306 207.665 7.62603 209.323 7.61198L209.302 7.95982C209.295 8.04415 209.277 8.34983 209.249 8.87686L209.186 10.0258C209.172 10.2717 209.165 10.5212 209.165 10.7742C207.809 10.718 206.67 10.6899 205.75 10.6899C204.738 10.6899 204.053 10.6934 203.694 10.7004C203.343 10.7004 202.791 10.7145 202.039 10.7426L201.913 13.1564C202.742 13.1775 203.708 13.188 204.812 13.188C205.205 13.188 206.252 13.1634 207.953 13.1142C207.882 14.0137 207.816 15.0853 207.752 16.3291C206.537 16.3151 205.602 16.308 204.949 16.308C203.979 16.308 202.893 16.3326 201.692 16.3818L201.565 18.9432H202.777C203.136 18.9432 203.877 18.9327 205.001 18.9116L207.183 18.8589C207.507 18.8519 208.048 18.8237 208.807 18.7746C208.722 19.8146 208.652 20.8897 208.596 22C206.881 21.9859 205.001 21.9789 202.956 21.9789L198.319 21.9895L196.822 22ZM211.104 22L211.315 19.3016L211.589 14.0313L211.705 10.2999C211.712 9.89931 211.716 9.49876 211.716 9.09822C211.716 8.79605 211.712 8.30064 211.705 7.61198C212.633 7.63306 213.378 7.6436 213.94 7.6436C214.481 7.6436 215.222 7.62955 216.164 7.60144C217.106 7.5663 217.903 7.54874 218.557 7.54874C219.498 7.54874 220.194 7.60847 220.644 7.72793C221.1 7.84739 221.512 8.03712 221.877 8.29712C222.242 8.5501 222.545 8.84875 222.783 9.19308C223.022 9.53741 223.202 9.93093 223.321 10.3736C223.441 10.8093 223.5 11.2555 223.5 11.7123C223.5 12.6539 223.272 13.5148 222.815 14.2948C222.358 15.0748 221.715 15.658 220.886 16.0445C220.057 16.431 219.126 16.6243 218.093 16.6243C217.713 16.6243 217.274 16.5821 216.775 16.4978C216.719 16.1745 216.614 15.6932 216.459 15.0537C216.304 14.4142 216.178 13.9399 216.08 13.6307C216.389 13.6939 216.712 13.7256 217.049 13.7256C217.759 13.7256 218.286 13.5569 218.63 13.2196C218.982 12.8753 219.157 12.4572 219.157 11.9653C219.157 11.7264 219.119 11.5155 219.042 11.3328C218.964 11.1431 218.841 10.9885 218.673 10.869C218.504 10.7496 218.318 10.6723 218.114 10.6372C217.91 10.602 217.678 10.5845 217.418 10.5845C217.165 10.5845 216.902 10.5915 216.628 10.6055L215.742 10.6688C215.651 12.025 215.581 13.5323 215.531 15.1907C215.44 18.2124 215.394 20.0254 215.394 20.6297V22L213.297 21.9789C213.002 21.9789 212.271 21.9859 211.104 22ZM224.607 22C224.635 21.6697 224.656 21.234 224.67 20.693C224.691 20.1448 224.716 19.5862 224.744 19.017C225.475 18.4197 226.146 17.8399 226.757 17.2778C227.748 16.3643 228.627 15.5069 229.392 14.7059C230.004 14.0664 230.517 13.4796 230.931 12.9456C231.17 12.6293 231.328 12.3658 231.406 12.155C231.462 12.0074 231.49 11.8564 231.49 11.7018C231.49 11.498 231.444 11.3223 231.353 11.1747C231.262 11.0201 231.118 10.8901 230.921 10.7847C230.724 10.6793 230.485 10.595 230.204 10.5317C229.923 10.4685 229.603 10.4369 229.245 10.4369C228.753 10.4369 228.233 10.479 227.685 10.5634C227.144 10.6477 226.402 10.8093 225.461 11.0482L225.735 9.54092C225.798 9.23876 225.889 8.65902 226.009 7.80171C227.857 7.49252 229.589 7.33792 231.205 7.33792C231.943 7.33792 232.65 7.40468 233.324 7.5382C233.999 7.67171 234.603 7.90009 235.137 8.22334C235.671 8.53956 236.072 8.912 236.339 9.34065C236.606 9.76228 236.739 10.219 236.739 10.7109C236.739 11.0342 236.701 11.3539 236.623 11.6701C236.546 11.9723 236.402 12.3272 236.191 12.7347C235.924 13.2407 235.594 13.7396 235.2 14.2315C234.617 14.9694 233.943 15.7318 233.177 16.5188C232.446 17.2637 231.567 18.1 230.541 19.0275L231.701 19.0381C231.975 19.0381 232.516 19.031 233.324 19.017C234.139 19.0029 234.705 18.9889 235.021 18.9748C235.337 18.9608 235.727 18.9292 236.191 18.88C236.17 19.2173 236.135 19.6389 236.086 20.1448C236.044 20.6508 235.998 21.2692 235.949 22C234.199 21.9859 232.368 21.9789 230.457 21.9789C228.152 21.9789 226.202 21.9859 224.607 22Z" fill="white"/>
|
||
|
||
<!-- 渐变定义 -->
|
||
<defs>
|
||
<linearGradient id="paint0_linear_1600_2316" x1="124.999" y1="23.28" x2="116.962" y2="23.246" gradientUnits="userSpaceOnUse">
|
||
<stop stop-color="white" stop-opacity="0.8"/>
|
||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||
</linearGradient>
|
||
<linearGradient id="paint1_linear_1600_2316" x1="118.036" y1="23.28" x2="109.999" y2="23.246" gradientUnits="userSpaceOnUse">
|
||
<stop stop-color="white" stop-opacity="0.8"/>
|
||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- 生成分镜图区域 - 只在第一步显示 -->
|
||
<div class="storyboard-section" v-if="currentStep === 'generate'">
|
||
<!-- 图片上传区域(支持多张参考图片) -->
|
||
<div class="image-input-section">
|
||
<div class="image-upload-row">
|
||
<!-- 3个上传框 -->
|
||
<div
|
||
v-for="slotIndex in 3"
|
||
:key="slotIndex"
|
||
class="upload-box-compact"
|
||
:class="{ uploaded: uploadedImages[slotIndex - 1], 'drag-over': draggingSlotIndex === slotIndex - 1 }"
|
||
@click="handleSlotClick(slotIndex - 1)"
|
||
@dragover.prevent="draggingSlotIndex = slotIndex - 1"
|
||
@dragleave.prevent="draggingSlotIndex = -1"
|
||
@drop.prevent="handleDropToSlot($event, slotIndex - 1)"
|
||
>
|
||
<!-- 已上传图片 -->
|
||
<template v-if="uploadedImages[slotIndex - 1]">
|
||
<div class="upload-preview-compact">
|
||
<img :src="uploadedImages[slotIndex - 1].url" :alt="t('video.storyboard.uploadedImage')" />
|
||
<button class="remove-btn-compact" @click.stop="removeImage(slotIndex - 1)">×</button>
|
||
</div>
|
||
<div class="image-label">{{ t('video.storyboard.imageLabel') }}{{ slotIndex }}</div>
|
||
</template>
|
||
<!-- 空白上传框 -->
|
||
<template v-else>
|
||
<div class="upload-placeholder-compact">
|
||
<svg class="upload-plus-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M7 0.5V13.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||
<path d="M0.5 7H13.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||
</svg>
|
||
<div class="upload-text-compact">{{ t('video.storyboard.uploadImage') }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="upload-hint-text">{{ t('video.storyboard.uploadHint') }}</div>
|
||
</div>
|
||
|
||
<div class="text-input-section">
|
||
<textarea
|
||
v-model="inputText"
|
||
:placeholder="t('video.storyboard.promptPlaceholder')"
|
||
class="text-input"
|
||
rows="6"
|
||
></textarea>
|
||
<div class="input-tips">
|
||
<div class="tip-item warning">{{ t('video.storyboard.tipWarning') }}</div>
|
||
<div class="tip-item">{{ t('video.storyboard.tip1') }}</div>
|
||
<div class="tip-item points">💎 {{ t('video.storyboard.imageCost') }}</div>
|
||
</div>
|
||
<div class="optimize-btn" v-if="!inProgress && taskId && (generatedImageUrl || taskStatus === 'FAILED')">
|
||
<button type="button" class="optimize-button" @click="handleStep1ButtonClick" :disabled="!inputText.trim()">
|
||
✨ {{ t('video.storyboard.regenerate') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图像生成模型选择 -->
|
||
<div class="image-model-settings">
|
||
<div class="setting-item">
|
||
<label>{{ t('video.storyboard.imageModel') }}</label>
|
||
<select v-model="imageModel" class="setting-select">
|
||
<option value="nano-banana">nano-banana</option>
|
||
<option value="nano-banana-2">nano-banana-2</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成视频区域 - 只在第二步显示 -->
|
||
<div class="storyboard-section" v-if="currentStep === 'video'">
|
||
<!-- 参考图预览区域 - 大图(只显示用户上传的参考图,不显示生成的分镜图) -->
|
||
<div class="video-reference-section">
|
||
<div
|
||
class="reference-image-large"
|
||
:class="{ 'drag-over': isDraggingMainRef }"
|
||
@click="uploadMainReferenceImage"
|
||
@dragover.prevent="isDraggingMainRef = true"
|
||
@dragleave.prevent="isDraggingMainRef = false"
|
||
@drop.prevent="handleDropMainReference"
|
||
>
|
||
<div v-if="mainReferenceImage" class="reference-preview">
|
||
<img :src="mainReferenceImage" :alt="t('video.storyboard.referenceImage')" />
|
||
<button class="reference-remove-btn" @click.stop="removeMainReferenceImage">×</button>
|
||
</div>
|
||
<div v-else class="reference-placeholder">
|
||
<svg class="upload-plus-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M12 4V20" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
|
||
<path d="M4 12H20" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
|
||
</svg>
|
||
<div class="placeholder-text">上传分镜图</div>
|
||
</div>
|
||
<div class="reference-label">分镜图</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部图片行:3个固定上传框 -->
|
||
<div class="video-images-row">
|
||
<div
|
||
v-for="slotIndex in 3"
|
||
:key="slotIndex"
|
||
class="video-upload-box-compact"
|
||
:class="{ uploaded: videoReferenceImages[slotIndex - 1], 'drag-over': draggingVideoSlotIndex === slotIndex - 1 }"
|
||
@click="handleVideoSlotClick(slotIndex - 1)"
|
||
@dragover.prevent="draggingVideoSlotIndex = slotIndex - 1"
|
||
@dragleave.prevent="draggingVideoSlotIndex = -1"
|
||
@drop.prevent="handleDropToVideoSlot($event, slotIndex - 1)"
|
||
>
|
||
<!-- 已上传图片 -->
|
||
<template v-if="videoReferenceImages[slotIndex - 1]">
|
||
<div class="video-preview-compact">
|
||
<img :src="videoReferenceImages[slotIndex - 1].url" :alt="'图' + slotIndex" />
|
||
<button class="video-remove-btn" @click.stop="removeVideoReferenceImage(slotIndex - 1)">×</button>
|
||
</div>
|
||
<div class="video-image-label">图{{ slotIndex }}</div>
|
||
</template>
|
||
<!-- 空白上传框 -->
|
||
<template v-else>
|
||
<div class="video-placeholder-compact">
|
||
<svg class="upload-plus-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M7 0.5V13.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||
<path d="M0.5 7H13.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||
</svg>
|
||
<div class="video-upload-text">上传图片</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频提示词输入区域 -->
|
||
<div class="text-input-section">
|
||
<textarea
|
||
v-model="videoPrompt"
|
||
:placeholder="t('video.storyboard.videoPromptPlaceholder')"
|
||
class="text-input"
|
||
rows="6"
|
||
></textarea>
|
||
<div class="input-tips">
|
||
<div class="tip-item">{{ t('video.storyboard.videoTip1') }}</div>
|
||
<div class="tip-item">{{ t('video.storyboard.videoTip2') }}</div>
|
||
<div class="tip-item points">💎 {{ t('video.storyboard.videoCost') }}</div>
|
||
</div>
|
||
<div class="optimize-btn" v-if="!inProgress && taskId && (videoResultUrl || taskStatus === 'FAILED')">
|
||
<button type="button" class="optimize-button" @click="handleStep2ButtonClick" :disabled="!mainReferenceImage">
|
||
✨ {{ t('video.storyboard.regenerate') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频设置 -->
|
||
<div class="video-settings">
|
||
<div class="setting-item">
|
||
<label>{{ t('video.aspectRatio') }}</label>
|
||
<select v-model="aspectRatio" class="setting-select">
|
||
<option value="16:9">16:9</option>
|
||
<option value="9:16">9:16</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="setting-item" v-if="currentStep === 'video'">
|
||
<label>{{ t('video.duration') }}</label>
|
||
<select v-model="duration" class="setting-select">
|
||
<option value="10">10s</option>
|
||
<option value="15">15s</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 高清模式暂时屏蔽
|
||
<div class="setting-item">
|
||
<label>{{ t('video.storyboard.hdMode') }}</label>
|
||
<div class="hd-setting">
|
||
<el-switch v-model="hdMode" />
|
||
<span class="cost-text">{{ t('video.storyboard.hdCost') }}</span>
|
||
</div>
|
||
</div>
|
||
-->
|
||
|
||
</div>
|
||
</div>
|
||
<!-- 左侧内容区域结束 -->
|
||
</div>
|
||
|
||
<!-- 悬浮的生成按钮 -->
|
||
<div class="generate-section floating">
|
||
<button
|
||
class="generate-btn"
|
||
@click="handleGenerateClick"
|
||
:disabled="isGenerateButtonDisabled"
|
||
>
|
||
{{ isAuthenticated ? getButtonText() : t('video.storyboard.pleaseLogin') }}
|
||
<span v-if="isAuthenticated" class="btn-points">30 <el-icon><Star /></el-icon></span>
|
||
</button>
|
||
<div v-if="!isAuthenticated" class="login-tip-floating">
|
||
<p>{{ t('video.storyboard.loginRequired') }}</p>
|
||
<button class="login-link-btn" @click="goToLogin">{{ t('video.storyboard.loginNow') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧预览区域 -->
|
||
<div class="right-panel">
|
||
<div class="preview-area">
|
||
<!-- 任务状态显示(有任务时显示) -->
|
||
<div class="task-status" v-if="currentTask">
|
||
<div class="status-header">
|
||
<h3>{{ getDisplayStatusText() }}</h3>
|
||
<div class="task-id">{{ t('home.storyboardVideo') }} {{ formatDate(currentTask.createdAt) }}</div>
|
||
</div>
|
||
|
||
<!-- 任务描述 -->
|
||
<div class="task-description" v-if="currentStep === 'generate' && inputText">
|
||
{{ inputText }}
|
||
</div>
|
||
<div class="task-description" v-else-if="currentStep === 'video' && videoPrompt">
|
||
{{ videoPrompt }}
|
||
</div>
|
||
|
||
<!-- 预览区域:根据当前步骤显示分镜图或视频 -->
|
||
<div class="video-preview-container">
|
||
<!-- 分镜图创作页面:显示分镜图 -->
|
||
<template v-if="currentStep === 'generate'">
|
||
<!-- 分镜图生成中(只有在分镜图还没生成完成时才显示) -->
|
||
<div v-if="inProgress && !generatedImageUrl" class="generating-container">
|
||
<div class="generating-placeholder">
|
||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingStoryboardText') }}</div>
|
||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||
<div class="progress-fill-indeterminate"></div>
|
||
</div>
|
||
<div v-else class="progress-bar-large">
|
||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||
</div>
|
||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||
</div>
|
||
</div>
|
||
<!-- AI生成的分镜图已完成 -->
|
||
<div v-else-if="generatedImageUrl && isAIGeneratedImage" class="storyboard-ready-container">
|
||
<div class="image-display-container">
|
||
<img
|
||
:src="generatedImageUrl"
|
||
:alt="t('video.storyboard.storyboardImage')"
|
||
class="result-image"
|
||
/>
|
||
</div>
|
||
<div class="storyboard-actions">
|
||
<div class="storyboard-ready-hint">{{ t('video.storyboard.storyboardReady') }}</div>
|
||
<button class="download-storyboard-btn" @click="downloadStoryboard">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
{{ t('video.download') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- 分镜图生成失败 -->
|
||
<div v-else-if="taskStatus === 'FAILED'" class="failed-container">
|
||
<div class="failed-placeholder">
|
||
<div class="failed-icon">❌</div>
|
||
<div class="failed-text">{{ t('video.storyboard.taskFailed') }}</div>
|
||
<div class="failed-desc">{{ t('video.storyboard.checkInputOrRetry') }}</div>
|
||
<div class="failed-reason" v-if="currentTask && currentTask.errorMessage">
|
||
<span class="reason-label">{{ t('video.failReason') }}:</span>
|
||
<span class="reason-text">{{ currentTask.errorMessage }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 初始状态 -->
|
||
<div v-else class="status-placeholder">
|
||
<div class="status-text">{{ t('video.storyboard.startCreating') }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 分镜视频创作页面:显示视频 -->
|
||
<template v-else-if="currentStep === 'video'">
|
||
<!-- 视频生成完成 -->
|
||
<div v-if="videoResultUrl" class="completed-container">
|
||
<div class="video-player-container">
|
||
<div class="video-player">
|
||
<video
|
||
:src="videoResultUrl"
|
||
controls
|
||
class="result-video"
|
||
preload="metadata"
|
||
></video>
|
||
</div>
|
||
</div>
|
||
<div class="result-actions">
|
||
<button class="action-btn primary" @click="createSimilarFromHistory(currentTask)">{{ t('profile.createSimilar') }}</button>
|
||
<div class="action-icons">
|
||
<button class="icon-btn" @click="downloadVideo" :title="t('video.imageToVideo.downloadVideo')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 视频生成中,显示简洁的进度条(与文生视频一致) -->
|
||
<div v-else-if="inProgress" class="generating-container">
|
||
<div class="generating-placeholder">
|
||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingVideoText') }}</div>
|
||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||
<div class="progress-fill-indeterminate"></div>
|
||
</div>
|
||
<div v-else class="progress-bar-large">
|
||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||
</div>
|
||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||
</div>
|
||
</div>
|
||
<!-- 视频生成失败 -->
|
||
<div v-else-if="taskStatus === 'FAILED'" class="failed-container">
|
||
<div class="failed-placeholder">
|
||
<div class="failed-icon">❌</div>
|
||
<div class="failed-text">{{ t('video.storyboard.taskFailed') }}</div>
|
||
<div class="failed-desc">{{ t('video.storyboard.checkInputOrRetry') }}</div>
|
||
<div class="failed-reason" v-if="currentTask && currentTask.errorMessage">
|
||
<span class="reason-label">{{ t('video.failReason') }}:</span>
|
||
<span class="reason-text">{{ currentTask.errorMessage }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 等待生成视频(有分镜图) -->
|
||
<div v-else-if="generatedImageUrl" class="storyboard-ready-container">
|
||
<div class="image-display-container">
|
||
<img
|
||
:src="generatedImageUrl"
|
||
:alt="t('video.storyboard.storyboardImage')"
|
||
class="result-image"
|
||
/>
|
||
</div>
|
||
<div class="storyboard-actions">
|
||
<div class="storyboard-ready-hint">{{ t('video.storyboard.readyForVideo') }}</div>
|
||
</div>
|
||
</div>
|
||
<!-- 初始状态 -->
|
||
<div v-else class="status-placeholder">
|
||
<div class="status-text">{{ t('video.storyboard.uploadStoryboardFirst') }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 初始状态(无任务时显示) -->
|
||
<div class="preview-content" v-else>
|
||
<!-- 分镜图创作页面 -->
|
||
<template v-if="currentStep === 'generate'">
|
||
<!-- 分镜图生成中 -->
|
||
<div v-if="inProgress && !generatedImageUrl" class="generating-container">
|
||
<div class="generating-placeholder">
|
||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingStoryboardText') }}</div>
|
||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||
<div class="progress-fill-indeterminate"></div>
|
||
</div>
|
||
<div v-else class="progress-bar-large">
|
||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||
</div>
|
||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="generatedImageUrl && isAIGeneratedImage" class="preview-image">
|
||
<img :src="generatedImageUrl" :alt="t('video.storyboard.storyboardImage')" />
|
||
</div>
|
||
<div v-else class="preview-placeholder">
|
||
<div class="placeholder-text">{{ t('video.storyboard.startCreating') }}</div>
|
||
</div>
|
||
</template>
|
||
<!-- 分镜视频创作页面 -->
|
||
<template v-else-if="currentStep === 'video'">
|
||
<div v-if="videoResultUrl" class="preview-video">
|
||
<video :src="videoResultUrl" controls preload="metadata" style="max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain;"></video>
|
||
</div>
|
||
<!-- 视频生成中 -->
|
||
<div v-else-if="inProgress" class="generating-container">
|
||
<div class="generating-placeholder">
|
||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingVideoText') }}</div>
|
||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||
<div class="progress-fill-indeterminate"></div>
|
||
</div>
|
||
<div v-else class="progress-bar-large">
|
||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||
</div>
|
||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="generatedImageUrl || mainReferenceImage" class="preview-image">
|
||
<img :src="generatedImageUrl || mainReferenceImage" :alt="t('video.storyboard.storyboardImage')" />
|
||
</div>
|
||
<div v-else class="preview-placeholder">
|
||
<div class="placeholder-text">{{ t('video.storyboard.uploadStoryboardFirst') }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 历史记录区域 -->
|
||
<div class="history-section" v-if="filteredHistoryTasks.length > 0">
|
||
<div class="history-list">
|
||
<div
|
||
v-for="task in filteredHistoryTasks"
|
||
:key="task.taskId"
|
||
class="history-item"
|
||
>
|
||
<!-- 顶部状态复选框 -->
|
||
<div class="history-status-checkbox" v-if="task.status === 'PENDING' || task.status === 'PROCESSING'">
|
||
<input type="checkbox" :checked="true" disabled>
|
||
<label>{{ t('video.storyboard.inProgress') }}</label>
|
||
</div>
|
||
|
||
<!-- 头部信息 -->
|
||
<div class="history-item-header">
|
||
<span class="history-type">{{ t('home.storyboardVideo') }}</span>
|
||
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
|
||
</div>
|
||
|
||
<!-- 描述文字 -->
|
||
<div class="history-prompt">{{ task.prompt || t('video.storyboard.noDescription') }}</div>
|
||
|
||
<!-- 预览区域 -->
|
||
<div class="history-preview">
|
||
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
|
||
<div class="queue-text">{{ t('video.storyboard.queuing') }}</div>
|
||
<div class="queue-link">{{ t('video.storyboard.subscribeToSpeed') }}</div>
|
||
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
|
||
</div>
|
||
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl && !isImageUrl(task.resultUrl)" class="history-video-thumbnail">
|
||
<video
|
||
:ref="el => setVideoRef(task.taskId, el)"
|
||
:src="processHistoryUrl(task.resultUrl)"
|
||
controls
|
||
preload="metadata"
|
||
@loadedmetadata="handleVideoLoaded"
|
||
@error="handleVideoError"
|
||
></video>
|
||
</div>
|
||
<div v-else-if="task.resultUrl && isImageUrl(task.resultUrl)" class="history-image-thumbnail">
|
||
<img :src="processHistoryUrl(task.resultUrl)" :alt="t('video.storyboard.storyboardImage')" @error="handleImageError" />
|
||
</div>
|
||
<div v-else class="history-placeholder">
|
||
<div class="no-result-text">{{ t('video.storyboard.noResult') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="history-actions">
|
||
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('profile.createSimilar') }}</button>
|
||
<!-- 分镜图历史记录:生成视频按钮 -->
|
||
<button
|
||
v-if="currentStep === 'generate' && task.status === 'COMPLETED' && task.resultUrl && isImageUrl(task.resultUrl)"
|
||
class="generate-video-btn"
|
||
@click="goToVideoStepWithStoryboard(task)"
|
||
:title="t('video.storyboard.generateVideo')"
|
||
>
|
||
{{ t('video.storyboard.generateVideo') }}
|
||
</button>
|
||
<!-- 分镜视频下载按钮 -->
|
||
<button
|
||
v-if="task.status === 'COMPLETED' && task.videoResultUrl"
|
||
class="download-btn"
|
||
@click="downloadHistoryVideo(task)"
|
||
:title="t('video.storyboard.downloadVideo')"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||
</svg>
|
||
</button>
|
||
<!-- 分镜图下载按钮 -->
|
||
<button
|
||
v-if="currentStep === 'generate' && task.status === 'COMPLETED' && task.resultUrl && isImageUrl(task.resultUrl)"
|
||
class="download-btn"
|
||
@click="downloadHistoryImage(task)"
|
||
:title="t('video.storyboard.downloadImage')"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
|
||
import { useUserStore } from '@/stores/user'
|
||
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks, retryStoryboardTask } from '@/api/storyboardVideo'
|
||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||
import { getProcessingWorks, getMyWorks } from '@/api/userWorks'
|
||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
|
||
const { t } = useI18n()
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const userStore = useUserStore()
|
||
|
||
// 计算是否已登录
|
||
const isAuthenticated = computed(() => userStore.isAuthenticated)
|
||
|
||
// 表单数据
|
||
const inputText = ref('')
|
||
const videoPrompt = ref('') // 视频生成提示词
|
||
const aspectRatio = ref('9:16')
|
||
const duration = ref('15')
|
||
const hdMode = ref(false)
|
||
const imageModel = ref('nano-banana-2')
|
||
const inProgress = ref(false)
|
||
const currentStep = ref('generate') // 'generate' 或 'video'
|
||
const userManuallyChangedStep = ref(false) // 用户是否手动切换了步骤,防止自动跳转
|
||
|
||
// 图片上传(支持最多6张)
|
||
const uploadedImages = ref([]) // 改为数组,支持多张图片
|
||
const generatedImageUrl = ref('')
|
||
const isAIGeneratedImage = ref(false) // 标记generatedImageUrl是否是AI生成的(而不是用户上传的)
|
||
const taskId = ref('')
|
||
const taskStatus = ref('') // 任务状态:PENDING, PROCESSING, COMPLETED, FAILED
|
||
const currentTask = ref(null) // 当前任务对象
|
||
const pollIntervalId = ref(null) // 保存轮询定时器ID
|
||
|
||
// 拖拽状态
|
||
const draggingSlotIndex = ref(-1) // 分镜图上传框拖拽状态
|
||
const draggingVideoSlotIndex = ref(-1) // 视频参考图上传框拖拽状态
|
||
const isDraggingMainRef = ref(false) // 主参考图拖拽状态
|
||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||
const optimizingVideoPrompt = ref(false) // 优化视频提示词状态
|
||
const videoReferenceImages = ref([]) // 视频参考图片数组
|
||
const mainReferenceImage = ref('') // 主参考图
|
||
|
||
// 用户菜单相关
|
||
const showUserMenu = ref(false)
|
||
const userAvatarRef = ref(null)
|
||
|
||
// 计算菜单位置
|
||
const menuStyle = computed(() => {
|
||
if (!userAvatarRef.value || !showUserMenu.value) return {}
|
||
|
||
const rect = userAvatarRef.value.getBoundingClientRect()
|
||
return {
|
||
position: 'fixed',
|
||
top: `${rect.bottom + 8}px`,
|
||
right: `${window.innerWidth - rect.right}px`,
|
||
zIndex: 99999
|
||
}
|
||
})
|
||
|
||
// 为了兼容性,保留 uploadedImage 作为计算属性(返回第一张图片)
|
||
const uploadedImage = computed(() => {
|
||
if (uploadedImages.value.length > 0 && uploadedImages.value[0]?.url) {
|
||
return uploadedImages.value[0].url
|
||
}
|
||
return ''
|
||
})
|
||
|
||
// 检查是否有上传的图片(用于按钮状态)
|
||
const hasUploadedImages = computed(() => {
|
||
return uploadedImages.value.length > 0
|
||
})
|
||
|
||
// 视频任务相关
|
||
const videoTaskId = ref('')
|
||
const videoPollIntervalId = ref(null) // 视频任务轮询定时器ID
|
||
const isCreatingTask = ref(false) // 标记是否正在创建任务,避免重复恢复
|
||
const hasRestoredTask = ref(false) // 标记是否已经恢复过任务
|
||
let isFromCreateSimilar = false // 标记是否从"做同款"进入(非响应式,仅用于onMounted判断)
|
||
const videoTaskStatus = ref('') // 视频任务状态:PROCESSING, COMPLETED, FAILED
|
||
const videoResultUrl = ref('') // 视频结果URL
|
||
const videoProgress = ref(0) // 视频生成进度
|
||
const storyboardHistoryTasks = ref([]) // 分镜图历史记录
|
||
const videoHistoryTasks = ref([]) // 视频历史记录
|
||
const playingVideos = ref({}) // 正在播放的视频
|
||
const videoRefs = ref({}) // 视频元素引用
|
||
|
||
// 根据当前步骤返回对应的历史记录
|
||
const filteredHistoryTasks = computed(() => {
|
||
if (currentStep.value === 'generate') {
|
||
return storyboardHistoryTasks.value
|
||
} else if (currentStep.value === 'video') {
|
||
return videoHistoryTasks.value
|
||
}
|
||
return []
|
||
})
|
||
|
||
// 导航函数
|
||
const goBack = () => {
|
||
router.push('/')
|
||
}
|
||
|
||
// 跳转到登录页面
|
||
const goToLogin = () => {
|
||
router.push({
|
||
path: '/login',
|
||
query: { redirect: router.currentRoute.value.fullPath }
|
||
})
|
||
}
|
||
|
||
const goToTextToVideo = () => {
|
||
router.push('/text-to-video/create')
|
||
}
|
||
|
||
const goToImageToVideo = () => {
|
||
router.push('/image-to-video/create')
|
||
}
|
||
|
||
// 用户菜单相关方法
|
||
const toggleUserMenu = () => {
|
||
// 未登录时跳转到登录页面
|
||
if (!isAuthenticated.value) {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
showUserMenu.value = !showUserMenu.value
|
||
}
|
||
|
||
const goToProfile = () => {
|
||
showUserMenu.value = false
|
||
router.push('/profile')
|
||
}
|
||
|
||
const goToMyWorks = () => {
|
||
showUserMenu.value = false
|
||
router.push('/works')
|
||
}
|
||
|
||
const goToSubscription = () => {
|
||
showUserMenu.value = false
|
||
router.push('/subscription')
|
||
}
|
||
|
||
const goToSettings = () => {
|
||
showUserMenu.value = false
|
||
router.push('/settings')
|
||
}
|
||
|
||
const goToDashboard = () => {
|
||
showUserMenu.value = false
|
||
router.push('/admin/dashboard')
|
||
}
|
||
|
||
const goToOrders = () => {
|
||
showUserMenu.value = false
|
||
router.push('/admin/orders')
|
||
}
|
||
|
||
const goToMembers = () => {
|
||
showUserMenu.value = false
|
||
router.push('/member-management')
|
||
}
|
||
|
||
const goToSystemSettings = () => {
|
||
showUserMenu.value = false
|
||
router.push('/system-settings')
|
||
}
|
||
|
||
const goToErrorStats = () => {
|
||
showUserMenu.value = false
|
||
router.push('/admin/error-statistics')
|
||
}
|
||
|
||
const goToApiManagement = () => {
|
||
showUserMenu.value = false
|
||
router.push('/api-management')
|
||
}
|
||
|
||
const goToTaskRecord = () => {
|
||
showUserMenu.value = false
|
||
router.push('/generate-task-record')
|
||
}
|
||
|
||
const goToChangePassword = () => {
|
||
showUserMenu.value = false
|
||
router.push('/change-password')
|
||
}
|
||
|
||
const logout = () => {
|
||
showUserMenu.value = false
|
||
userStore.logout()
|
||
router.push('/login')
|
||
}
|
||
|
||
// 切换到生成分镜图步骤
|
||
const switchToGenerateStep = () => {
|
||
currentStep.value = 'generate'
|
||
userManuallyChangedStep.value = true // 标记用户手动切换
|
||
}
|
||
|
||
// 切换到视频步骤
|
||
const switchToVideoStep = () => {
|
||
// 允许直接切换到视频步骤,不再强制要求先生成分镜图
|
||
currentStep.value = 'video'
|
||
userManuallyChangedStep.value = true // 标记用户手动切换
|
||
}
|
||
|
||
// 判断URL是否为图片(支持 Base64 和 COS URL)
|
||
const isImageUrl = (url) => {
|
||
if (!url) return false
|
||
// Base64 图片
|
||
if (url.startsWith('data:image')) return true
|
||
// 通过文件扩展名判断
|
||
return /\.(png|jpg|jpeg|gif|webp|bmp)(\?|$)/i.test(url)
|
||
}
|
||
|
||
// 检测是否为积分不足错误
|
||
const isInsufficientPointsError = (errorMsg) => {
|
||
return errorMsg && (
|
||
errorMsg.includes('积分不足') ||
|
||
errorMsg.includes('insufficient') ||
|
||
errorMsg.includes('points')
|
||
)
|
||
}
|
||
|
||
// 处理积分不足错误(统一弹窗提示)
|
||
const handleInsufficientPointsError = (errorMsg, fallbackMsg) => {
|
||
if (isInsufficientPointsError(errorMsg)) {
|
||
ElMessageBox.confirm(
|
||
'您的积分不足,无法创建任务。是否前往充值?',
|
||
'积分不足',
|
||
{
|
||
confirmButtonText: '去充值',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}
|
||
).then(() => {
|
||
router.push('/subscription')
|
||
}).catch(() => {})
|
||
return true
|
||
}
|
||
if (fallbackMsg) {
|
||
ElMessage.error(fallbackMsg)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 静默刷新用户积分
|
||
const refreshUserPoints = () => {
|
||
setTimeout(async () => {
|
||
try {
|
||
await userStore.fetchCurrentUser()
|
||
} catch (error) {
|
||
// 积分更新失败不影响任务流程
|
||
}
|
||
}, 0)
|
||
}
|
||
|
||
const isGenerateButtonDisabled = computed(() => {
|
||
if (!userStore.isAuthenticated) return true
|
||
if (isCreatingTask.value) return true
|
||
|
||
// 如果任务失败,允许重试(按钮可用)
|
||
if (taskStatus.value === 'FAILED') return false
|
||
|
||
if (currentStep.value === 'generate') {
|
||
return !hasUploadedImages.value && !inputText.value.trim()
|
||
}
|
||
|
||
if (currentStep.value === 'video') {
|
||
// 有分镜图(AI生成或用户上传)或有上传的参考图,都可以生成视频
|
||
return !generatedImageUrl.value && !mainReferenceImage.value && !hasUploadedImages.value
|
||
}
|
||
|
||
return true
|
||
})
|
||
|
||
// 重置任务创建状态
|
||
const resetTaskCreationState = () => {
|
||
inProgress.value = false
|
||
isCreatingTask.value = false
|
||
currentTask.value = null
|
||
taskStatus.value = ''
|
||
generatedImageUrl.value = ''
|
||
}
|
||
|
||
// 获取按钮文本
|
||
const getButtonText = () => {
|
||
if (currentStep.value === 'video') {
|
||
// 如果分镜图已生成,显示"开始生成视频"
|
||
if (generatedImageUrl.value && taskId.value) {
|
||
return t('video.storyboard.startGenerateVideo')
|
||
}
|
||
// 如果有上传的图片,显示"生成视频"
|
||
if (hasUploadedImages.value) {
|
||
return t('video.generate')
|
||
}
|
||
return t('video.generate')
|
||
}
|
||
// 第一步(生成分镜图):根据是否有参考图和提示词显示不同文本
|
||
return t('video.storyboard.generateStoryboard')
|
||
}
|
||
|
||
// 处理生成按钮点击
|
||
const handleGenerateClick = async () => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeSubmit'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
|
||
// 如果已经切换到视频步骤,直接生成视频
|
||
if (currentStep.value === 'video') {
|
||
await startVideoGenerate()
|
||
return
|
||
}
|
||
|
||
// 第一步的逻辑:调用banana模型生成分镜图
|
||
// 1. 如果有上传的参考图和提示词,调用带参考图的API生成分镜图
|
||
// 2. 如果只有提示词,调用不带参考图的API生成分镜图
|
||
// 3. 如果只有参考图没有提示词,提示用户输入描述
|
||
|
||
if (hasUploadedImages.value && !inputText.value.trim()) {
|
||
// 有参考图但没有提示词,提示用户输入描述
|
||
ElMessage.warning(t('video.storyboard.enterDescriptionForImage'))
|
||
return
|
||
}
|
||
|
||
if (inputText.value.trim()) {
|
||
// 有提示词,调用API生成分镜图(无论是否有参考图)
|
||
// 如果有参考图,后端会调用图生图API(/v1/images/edits)
|
||
// 如果没有参考图,后端会调用文生图API(/v1/images/generations)
|
||
startGenerate()
|
||
return
|
||
}
|
||
|
||
// 如果都没有,提示用户
|
||
ElMessage.warning(t('video.storyboard.uploadOrInputPrompt'))
|
||
}
|
||
|
||
// 处理上传按钮点击
|
||
const handleUploadClick = () => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeUpload'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
uploadImage()
|
||
}
|
||
|
||
// 处理上传框点击(指定位置)
|
||
const handleSlotClick = (slotIndex) => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeUpload'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
// 如果该位置已有图片,不处理(删除通过删除按钮)
|
||
if (uploadedImages.value[slotIndex]) {
|
||
return
|
||
}
|
||
uploadImageToSlot(slotIndex)
|
||
}
|
||
|
||
// 图片上传处理(最多3张参考图片)
|
||
const uploadImage = () => {
|
||
// 找到第一个空位置
|
||
let slotIndex = uploadedImages.value.length
|
||
if (slotIndex >= 3) {
|
||
ElMessage.warning(t('video.storyboard.maxImagesWarning'))
|
||
return
|
||
}
|
||
uploadImageToSlot(slotIndex)
|
||
}
|
||
|
||
// 上传图片到指定位置
|
||
const uploadImageToSlot = (slotIndex) => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.multiple = false // 每次只选择1张图片
|
||
input.onchange = (e) => {
|
||
const files = Array.from(e.target.files || [])
|
||
|
||
if (files.length === 0) return
|
||
|
||
// 只取第一个文件
|
||
const file = files[0]
|
||
|
||
// 验证文件大小(最大100MB)
|
||
const maxFileSize = 100 * 1024 * 1024 // 100MB
|
||
if (file.size > maxFileSize) {
|
||
ElMessage.error(t('video.storyboard.fileSizeLimit'))
|
||
return
|
||
}
|
||
|
||
// 验证文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.error(t('video.storyboard.invalidFileType'))
|
||
return
|
||
}
|
||
|
||
// 读取文件并设置到指定位置
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
// 确保数组长度足够
|
||
while (uploadedImages.value.length <= slotIndex) {
|
||
uploadedImages.value.push(null)
|
||
}
|
||
uploadedImages.value[slotIndex] = {
|
||
url: e.target.result,
|
||
file: file,
|
||
name: file.name
|
||
}
|
||
// 使用最新上传的图片作为预览(但不标记为AI生成)
|
||
generatedImageUrl.value = e.target.result
|
||
isAIGeneratedImage.value = false // 用户上传的,不是AI生成的
|
||
ElMessage.success(t('video.storyboard.uploadSuccess', { count: 1 }))
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
// 删除指定索引的图片
|
||
const removeImage = (index) => {
|
||
if (index >= 0 && index < uploadedImages.value.length && uploadedImages.value[index]) {
|
||
const removedImage = uploadedImages.value[index]
|
||
// 将该位置设为 null,保持数组长度
|
||
uploadedImages.value[index] = null
|
||
|
||
// 如果删除的图片是当前用作分镜图的图片,更新分镜图
|
||
if (generatedImageUrl.value === removedImage.url) {
|
||
// 找到第一张非空图片
|
||
const firstImage = uploadedImages.value.find(img => img !== null)
|
||
if (firstImage) {
|
||
generatedImageUrl.value = firstImage.url
|
||
} else {
|
||
generatedImageUrl.value = ''
|
||
}
|
||
}
|
||
|
||
ElMessage.success(t('video.storyboard.imageRemoved'))
|
||
}
|
||
}
|
||
|
||
// 处理拖拽上传到指定槽位(分镜图参考图)
|
||
const handleDropToSlot = (e, slotIndex) => {
|
||
draggingSlotIndex.value = -1
|
||
const files = e.dataTransfer.files
|
||
if (files.length === 0) return
|
||
|
||
const file = files[0]
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.error(t('video.storyboard.invalidFileType'))
|
||
return
|
||
}
|
||
|
||
const maxFileSize = 100 * 1024 * 1024 // 100MB
|
||
if (file.size > maxFileSize) {
|
||
ElMessage.error(t('video.storyboard.fileSizeLimit'))
|
||
return
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
while (uploadedImages.value.length <= slotIndex) {
|
||
uploadedImages.value.push(null)
|
||
}
|
||
uploadedImages.value[slotIndex] = {
|
||
url: e.target.result,
|
||
file: file,
|
||
name: file.name
|
||
}
|
||
generatedImageUrl.value = e.target.result
|
||
isAIGeneratedImage.value = false // 用户上传的,不是AI生成的
|
||
ElMessage.success(t('video.storyboard.uploadSuccess', { count: 1 }))
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
// 处理拖拽上传到视频参考图槽位
|
||
const handleDropToVideoSlot = (e, slotIndex) => {
|
||
draggingVideoSlotIndex.value = -1
|
||
const files = e.dataTransfer.files
|
||
if (files.length === 0) return
|
||
|
||
const file = files[0]
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.error(t('video.storyboard.invalidFileType'))
|
||
return
|
||
}
|
||
|
||
const maxFileSize = 100 * 1024 * 1024 // 100MB
|
||
if (file.size > maxFileSize) {
|
||
ElMessage.error(t('video.storyboard.fileSizeLimit'))
|
||
return
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
while (videoReferenceImages.value.length <= slotIndex) {
|
||
videoReferenceImages.value.push(null)
|
||
}
|
||
videoReferenceImages.value[slotIndex] = {
|
||
url: e.target.result,
|
||
file: file,
|
||
name: file.name
|
||
}
|
||
ElMessage.success(t('video.storyboard.uploadSuccess', { count: 1 }))
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
// 处理拖拽上传主参考图
|
||
const handleDropMainReference = (e) => {
|
||
isDraggingMainRef.value = false
|
||
const files = e.dataTransfer.files
|
||
if (files.length === 0) return
|
||
|
||
const file = files[0]
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.error(t('video.storyboard.invalidFileType'))
|
||
return
|
||
}
|
||
|
||
const maxFileSize = 100 * 1024 * 1024 // 100MB
|
||
if (file.size > maxFileSize) {
|
||
ElMessage.error(t('video.storyboard.fileSizeLimit'))
|
||
return
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
mainReferenceImage.value = e.target.result
|
||
ElMessage.success(t('video.storyboard.uploadSuccess', { count: 1 }))
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
// 处理视频参考图片槽位点击
|
||
const handleVideoSlotClick = (index) => {
|
||
// 如果该槽位已有图片,不做任何操作
|
||
if (videoReferenceImages.value[index]) {
|
||
return
|
||
}
|
||
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (file) {
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
ElMessage.error(t('video.storyboard.fileSizeLimit'))
|
||
return
|
||
}
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
// 确保数组有足够的长度
|
||
while (videoReferenceImages.value.length <= index) {
|
||
videoReferenceImages.value.push(null)
|
||
}
|
||
videoReferenceImages.value[index] = {
|
||
url: event.target.result,
|
||
file: file
|
||
}
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
// 删除视频参考图片
|
||
const removeVideoReferenceImage = (index) => {
|
||
if (index >= 0 && index < videoReferenceImages.value.length) {
|
||
videoReferenceImages.value[index] = null
|
||
}
|
||
}
|
||
|
||
// 上传主参考图
|
||
const uploadMainReferenceImage = () => {
|
||
// 如果已有图片,不做任何操作(需要先删除)
|
||
if (mainReferenceImage.value) {
|
||
return
|
||
}
|
||
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (file) {
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
ElMessage.error(t('video.storyboard.fileSizeLimit'))
|
||
return
|
||
}
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
mainReferenceImage.value = event.target.result
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
// 删除主参考图
|
||
const removeMainReferenceImage = () => {
|
||
mainReferenceImage.value = ''
|
||
}
|
||
|
||
// 优化提示词
|
||
const optimizePromptHandler = async () => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeOptimize'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
|
||
if (!inputText.value.trim()) {
|
||
ElMessage.warning(t('video.storyboard.enterPrompt'))
|
||
return
|
||
}
|
||
|
||
// 长度检查
|
||
if (inputText.value.length > 2000) {
|
||
ElMessage.warning(t('video.storyboard.promptTooLong'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
optimizingPrompt.value = true
|
||
const loading = ElLoading.service({
|
||
lock: false,
|
||
text: t('video.storyboard.optimizingPrompt'),
|
||
background: 'rgba(0, 0, 0, 0.3)'
|
||
})
|
||
|
||
const response = await optimizePrompt(inputText.value.trim(), 'storyboard')
|
||
|
||
loading.close()
|
||
|
||
if (response.data && response.data.success) {
|
||
const data = response.data.data
|
||
const optimized = data.optimizedPrompt
|
||
|
||
// 检查是否真正优化了
|
||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||
inputText.value = optimized
|
||
ElMessage.success(t('video.storyboard.optimizeSuccess'))
|
||
} else {
|
||
ElMessage.warning(t('video.storyboard.alreadyOptimized'))
|
||
}
|
||
} else {
|
||
ElMessage.error(response.data?.message || t('video.storyboard.optimizeFailed'))
|
||
}
|
||
} catch (error) {
|
||
console.error('优化提示词失败:', error)
|
||
|
||
let errorMessage = t('video.storyboard.optimizeFailed')
|
||
if (error.response) {
|
||
const status = error.response.status
|
||
if (status === 400) {
|
||
errorMessage = error.response.data?.message || t('video.storyboard.paramError')
|
||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||
errorMessage = t('video.storyboard.timeout')
|
||
} else if (status >= 500) {
|
||
errorMessage = t('video.storyboard.serverError')
|
||
} else {
|
||
errorMessage = error.response.data?.message || t('video.storyboard.optimizeFailed')
|
||
}
|
||
} else if (error.request) {
|
||
errorMessage = t('video.storyboard.networkError')
|
||
} else if (error.code === 'ERR_NETWORK') {
|
||
errorMessage = t('video.storyboard.networkError')
|
||
} else {
|
||
errorMessage = error.message || t('video.storyboard.optimizeFailed')
|
||
}
|
||
|
||
ElMessage.error(errorMessage)
|
||
} finally {
|
||
optimizingPrompt.value = false
|
||
}
|
||
}
|
||
|
||
// 优化视频提示词
|
||
const optimizeVideoPromptHandler = async () => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeOptimize'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
|
||
if (!videoPrompt.value.trim()) {
|
||
ElMessage.warning(t('video.storyboard.enterPrompt'))
|
||
return
|
||
}
|
||
|
||
// 长度检查
|
||
if (videoPrompt.value.length > 2000) {
|
||
ElMessage.warning(t('video.storyboard.promptTooLong'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
optimizingVideoPrompt.value = true
|
||
const loading = ElLoading.service({
|
||
lock: false,
|
||
text: t('video.storyboard.optimizingPrompt'),
|
||
background: 'rgba(0, 0, 0, 0.3)'
|
||
})
|
||
|
||
const response = await optimizePrompt(videoPrompt.value.trim(), 'video')
|
||
|
||
loading.close()
|
||
|
||
if (response.data && response.data.success) {
|
||
const data = response.data.data
|
||
const optimized = data.optimizedPrompt
|
||
|
||
// 检查是否真正优化了
|
||
if (data.optimized && optimized !== videoPrompt.value.trim()) {
|
||
videoPrompt.value = optimized
|
||
ElMessage.success(t('video.storyboard.optimizeSuccess'))
|
||
} else {
|
||
ElMessage.warning(t('video.storyboard.alreadyOptimized'))
|
||
}
|
||
} else {
|
||
ElMessage.error(response.data?.message || t('video.storyboard.optimizeFailed'))
|
||
}
|
||
} catch (error) {
|
||
console.error('优化视频提示词失败:', error)
|
||
|
||
let errorMessage = t('video.storyboard.optimizeFailed')
|
||
if (error.response) {
|
||
const status = error.response.status
|
||
if (status === 400) {
|
||
errorMessage = error.response.data?.message || t('video.storyboard.paramError')
|
||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||
errorMessage = t('video.storyboard.timeout')
|
||
} else if (status >= 500) {
|
||
errorMessage = t('video.storyboard.serverError')
|
||
} else {
|
||
errorMessage = error.response.data?.message || t('video.storyboard.optimizeFailed')
|
||
}
|
||
} else if (error.request) {
|
||
errorMessage = t('video.storyboard.networkError')
|
||
} else if (error.code === 'ERR_NETWORK') {
|
||
errorMessage = t('video.storyboard.networkError')
|
||
} else {
|
||
errorMessage = error.message || t('video.storyboard.optimizeFailed')
|
||
}
|
||
|
||
ElMessage.error(errorMessage)
|
||
} finally {
|
||
optimizingVideoPrompt.value = false
|
||
}
|
||
}
|
||
|
||
// 开始生成分镜图
|
||
const startGenerate = async () => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeSubmit'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
|
||
if (!inputText.value.trim()) {
|
||
ElMessage.warning(t('video.storyboard.enterDescription'))
|
||
return
|
||
}
|
||
|
||
// 标记正在创建任务
|
||
isCreatingTask.value = true
|
||
// 用户开始新任务,重置手动切换标志,允许后续自动切换
|
||
userManuallyChangedStep.value = false
|
||
// 重置AI生成标志,因为新任务还没生成
|
||
isAIGeneratedImage.value = false
|
||
// 清空之前的分镜图,确保显示"正在生成中"状态
|
||
generatedImageUrl.value = ''
|
||
// 清空之前的视频结果
|
||
videoResultUrl.value = ''
|
||
|
||
try {
|
||
inProgress.value = true
|
||
ElMessage.info(t('video.storyboard.startingGenerate'))
|
||
|
||
// 收集用户上传的图片(过滤掉null值)
|
||
const validUploadedImages = uploadedImages.value
|
||
.filter(img => img !== null)
|
||
.map(img => img.url)
|
||
|
||
// 调试:显示上传图片数量
|
||
console.log('[DEBUG] uploadedImages数组:', uploadedImages.value)
|
||
console.log('[DEBUG] 有效上传图片数量:', validUploadedImages.length)
|
||
if (validUploadedImages.length > 0) {
|
||
console.log('[DEBUG] 第一张图片URL前100字符:', validUploadedImages[0].substring(0, 100))
|
||
}
|
||
|
||
// 调用API创建任务
|
||
// 注意:只使用 uploadedImages,不再使用旧的 imageUrl 字段
|
||
const response = await createStoryboardTask({
|
||
prompt: inputText.value,
|
||
aspectRatio: aspectRatio.value,
|
||
hdMode: hdMode.value,
|
||
imageUrl: null, // 不再使用旧的单张图片字段
|
||
imageModel: imageModel.value,
|
||
uploadedImages: validUploadedImages.length > 0 ? validUploadedImages : null
|
||
})
|
||
|
||
if (response.data && response.data.success) {
|
||
ElMessage.success(t('video.storyboard.taskCreated'))
|
||
taskId.value = response.data.data.taskId
|
||
|
||
// 设置 currentTask 以便右侧预览区域显示生成状态
|
||
currentTask.value = {
|
||
taskId: response.data.data.taskId,
|
||
status: 'PROCESSING',
|
||
createdAt: new Date().toISOString(),
|
||
prompt: inputText.value
|
||
}
|
||
taskStatus.value = 'PROCESSING'
|
||
|
||
refreshUserPoints()
|
||
|
||
// 开始轮询任务状态,获取生成的图片
|
||
// inProgress 将在轮询完成时设置为 false
|
||
pollTaskStatus(response.data.data.taskId)
|
||
|
||
// 延迟重置创建标志,避免立即触发恢复逻辑
|
||
setTimeout(() => {
|
||
isCreatingTask.value = false
|
||
}, 2000)
|
||
} else {
|
||
// 任务创建失败,重置所有状态
|
||
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
|
||
handleInsufficientPointsError(errorMsg, errorMsg)
|
||
resetTaskCreationState()
|
||
}
|
||
} catch (error) {
|
||
const errorMsg = error.response?.data?.message || error.message || ''
|
||
handleInsufficientPointsError(errorMsg, t('video.storyboard.generateFailed') + ': ' + errorMsg)
|
||
resetTaskCreationState()
|
||
}
|
||
}
|
||
|
||
// 下载分镜图
|
||
const downloadStoryboard = async () => {
|
||
if (!generatedImageUrl.value) {
|
||
ElMessage.warning('没有可下载的分镜图')
|
||
return
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在准备下载...')
|
||
const { downloadImage } = await import('@/utils/download')
|
||
const success = await downloadImage(generatedImageUrl.value, taskId.value || Date.now(), 'storyboard')
|
||
if (success) {
|
||
ElMessage.success(t('common.downloadSuccess') || '下载成功')
|
||
}
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
window.open(generatedImageUrl.value, '_blank')
|
||
}
|
||
}
|
||
|
||
// 重新生成分镜图(不满意时使用)
|
||
const regenerateStoryboard = async () => {
|
||
// 确认是否重新生成
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
t('video.storyboard.regenerateConfirm'),
|
||
t('video.storyboard.regenerateTitle'),
|
||
{
|
||
confirmButtonText: t('common.confirm') || '确定',
|
||
cancelButtonText: t('common.cancel') || '取消',
|
||
type: 'warning'
|
||
}
|
||
)
|
||
} catch {
|
||
// 用户取消
|
||
return
|
||
}
|
||
|
||
// 重置分镜图相关状态
|
||
generatedImageUrl.value = ''
|
||
taskId.value = ''
|
||
taskStatus.value = ''
|
||
currentTask.value = null
|
||
|
||
// 切换回生成分镜图步骤
|
||
currentStep.value = 'generate'
|
||
|
||
// 调用生成函数
|
||
await startGenerate()
|
||
}
|
||
|
||
// 处理Step 1按钮点击
|
||
const handleStep1ButtonClick = () => {
|
||
if (taskStatus.value === 'FAILED') {
|
||
retryFailedTask()
|
||
} else {
|
||
startGenerate()
|
||
}
|
||
}
|
||
|
||
// 处理Step 2按钮点击
|
||
const handleStep2ButtonClick = () => {
|
||
console.log('[Step 2 Button Click] taskStatus:', taskStatus.value, 'inProgress:', inProgress.value, 'mainReferenceImage:', !!mainReferenceImage.value, 'taskId:', taskId.value)
|
||
if (taskStatus.value === 'FAILED') {
|
||
retryFailedTask()
|
||
} else {
|
||
regenerateVideo()
|
||
}
|
||
}
|
||
|
||
// 重新生成视频(使用分镜图、参考图和videoPrompt)
|
||
const regenerateVideo = async () => {
|
||
console.log('[regenerateVideo] called')
|
||
console.log('[regenerateVideo] taskId:', taskId.value)
|
||
console.log('[regenerateVideo] generatedImageUrl:', generatedImageUrl.value ? generatedImageUrl.value.substring(0, 50) : 'null')
|
||
console.log('[regenerateVideo] mainReferenceImage:', mainReferenceImage.value ? mainReferenceImage.value.substring(0, 50) : 'null')
|
||
|
||
// 检查是否有分镜图
|
||
if (!mainReferenceImage.value) {
|
||
ElMessage.warning('请先上传分镜图')
|
||
return
|
||
}
|
||
|
||
// 确保 generatedImageUrl 有值(从 mainReferenceImage 恢复)
|
||
if (!generatedImageUrl.value && mainReferenceImage.value) {
|
||
generatedImageUrl.value = mainReferenceImage.value
|
||
console.log('[regenerateVideo] 从 mainReferenceImage 恢复 generatedImageUrl')
|
||
}
|
||
|
||
// 确保在视频生成步骤(用户主动操作,重置手动切换标志)
|
||
currentStep.value = 'video'
|
||
userManuallyChangedStep.value = false // 用户开始新操作,允许后续自动切换
|
||
|
||
// 重置视频相关状态(保留分镜图,只重置视频)
|
||
videoResultUrl.value = ''
|
||
videoTaskStatus.value = ''
|
||
|
||
// 重置任务状态,允许重新生成
|
||
taskStatus.value = ''
|
||
// 不要在这里设置 inProgress = false,让 startVideoGenerate 设置为 true
|
||
|
||
console.log('[regenerateVideo] 开始调用 startVideoGenerate')
|
||
// 调用生成视频函数(不是生成分镜图)
|
||
await startVideoGenerate()
|
||
console.log('[regenerateVideo] startVideoGenerate 调用完成')
|
||
}
|
||
|
||
// 重试失败的任务(复用原来的task_id)
|
||
const retryFailedTask = async () => {
|
||
console.log('[retryFailedTask] called, taskId:', taskId.value, 'currentTask.taskId:', currentTask.value?.taskId)
|
||
// 检查是否有失败的任务
|
||
if (!taskId.value && !currentTask.value?.taskId) {
|
||
ElMessage.warning('没有可重试的任务')
|
||
return
|
||
}
|
||
|
||
const retryTaskId = taskId.value || currentTask.value?.taskId
|
||
|
||
try {
|
||
isCreatingTask.value = true
|
||
inProgress.value = true
|
||
taskStatus.value = 'PROCESSING'
|
||
ElMessage.info('正在重新生成...')
|
||
|
||
// 调用重试 API
|
||
const response = await retryStoryboardTask(retryTaskId)
|
||
|
||
if (response.data && response.data.success) {
|
||
ElMessage.success('重试任务已提交')
|
||
|
||
// 刷新用户积分
|
||
refreshUserPoints()
|
||
|
||
// 开始轮询任务状态
|
||
pollTaskStatus(retryTaskId)
|
||
} else {
|
||
throw new Error(response.data?.message || '重试失败')
|
||
}
|
||
} catch (error) {
|
||
const errorMsg = error.response?.data?.message || error.message || '重试失败'
|
||
ElMessage.error(errorMsg)
|
||
taskStatus.value = 'FAILED'
|
||
inProgress.value = false
|
||
isCreatingTask.value = false
|
||
}
|
||
}
|
||
|
||
// 轮询任务状态
|
||
const pollTaskStatus = async (taskId) => {
|
||
// 清除之前的轮询(如果存在)
|
||
if (pollIntervalId.value) {
|
||
clearTimeout(pollIntervalId.value)
|
||
pollIntervalId.value = null
|
||
}
|
||
|
||
// 统一使用2分钟轮询间隔
|
||
const maxAttempts = 90 // 最大尝试次数(足够覆盖整个流程)
|
||
let attempts = 0
|
||
let currentInterval = 120000 // 2分钟轮询一次
|
||
let promptWaitAttempts = 0
|
||
|
||
const poll = async () => {
|
||
// 先检查是否超过最大尝试次数
|
||
if (attempts >= maxAttempts) {
|
||
if (pollIntervalId.value) {
|
||
clearTimeout(pollIntervalId.value)
|
||
pollIntervalId.value = null
|
||
}
|
||
inProgress.value = false
|
||
ElMessage.warning(t('video.storyboard.taskTimeout'))
|
||
return
|
||
}
|
||
|
||
attempts++
|
||
|
||
try {
|
||
// 调用获取任务详情的API
|
||
const response = await getStoryboardTask(taskId)
|
||
|
||
if (response.data.success && response.data.data) {
|
||
const task = response.data.data
|
||
const currentStatus = String(task.status || '').toUpperCase()
|
||
const taskProgress = Number(task.progress) || 0
|
||
const taskResultUrl = task.resultUrl || ''
|
||
|
||
// 保存当前任务信息(包含 uploadedImages 等字段,用于重新生成时获取参考图)
|
||
currentTask.value = task
|
||
|
||
console.log(`[轮询] taskId=${taskId}, status=${currentStatus}, progress=${taskProgress}%, resultUrl=${taskResultUrl ? '有' : '无'}, uploadedImages=${task.uploadedImages ? '有' : '无'}`)
|
||
|
||
// 统一使用2分钟轮询间隔
|
||
const newInterval = 120000
|
||
|
||
if (newInterval !== currentInterval) {
|
||
currentInterval = newInterval
|
||
}
|
||
|
||
// 最优先检查:任务失败或取消
|
||
if (currentStatus === 'FAILED' || currentStatus === 'CANCELLED') {
|
||
if (pollIntervalId.value) {
|
||
clearTimeout(pollIntervalId.value)
|
||
pollIntervalId.value = null
|
||
}
|
||
inProgress.value = false
|
||
taskStatus.value = currentStatus
|
||
|
||
// 任务失败时恢复参考图和提示词,方便用户重试
|
||
// 恢复 imagePrompt 到生图提示词框
|
||
if (task.imagePrompt && task.imagePrompt.trim()) {
|
||
inputText.value = task.imagePrompt
|
||
console.log('[FAILED] 已恢复 imagePrompt')
|
||
} else if (task.prompt && task.prompt.trim()) {
|
||
// 如果没有 imagePrompt,使用原始 prompt
|
||
inputText.value = task.prompt
|
||
console.log('[FAILED] 已恢复原始 prompt')
|
||
}
|
||
|
||
// 恢复 videoPrompt 到视频提示词框
|
||
if (task.videoPrompt && task.videoPrompt.trim()) {
|
||
videoPrompt.value = task.videoPrompt
|
||
console.log('[FAILED] 已恢复 videoPrompt')
|
||
}
|
||
|
||
// 恢复用户上传的参考图
|
||
if (task.uploadedImages) {
|
||
try {
|
||
let parsedImages = task.uploadedImages
|
||
// 如果是字符串,需要解析 JSON
|
||
if (typeof task.uploadedImages === 'string') {
|
||
parsedImages = JSON.parse(task.uploadedImages)
|
||
}
|
||
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
|
||
// 清空现有图片,填入恢复的图片
|
||
uploadedImages.value = parsedImages
|
||
.filter(img => img && img !== 'null')
|
||
.map((url, index) => ({
|
||
url: url,
|
||
file: null, // 恢复的图片没有 file 对象
|
||
name: `参考图${index + 1}`
|
||
}))
|
||
console.log('[FAILED] 已恢复参考图:', uploadedImages.value.length, '张')
|
||
}
|
||
} catch (e) {
|
||
console.warn('[FAILED] 解析参考图失败:', e)
|
||
}
|
||
}
|
||
|
||
// 恢复已生成的分镜图(如果有)
|
||
if (task.resultUrl && task.resultUrl.trim()) {
|
||
generatedImageUrl.value = task.resultUrl
|
||
mainReferenceImage.value = task.resultUrl
|
||
// 如果视频生成失败但分镜图已生成,只在用户没有手动切换时才跳到视频生成步骤
|
||
if (!userManuallyChangedStep.value) {
|
||
currentStep.value = 'video'
|
||
}
|
||
console.log('[FAILED] 已恢复分镜图:', task.resultUrl.substring(0, 50))
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// 优先检查:如果 resultUrl 存在且是图片(Base64 或 COS URL),说明分镜图已生成
|
||
// 无论 progress 是多少,只要有 resultUrl 就应该显示
|
||
const hasValidImageUrl = taskResultUrl && taskResultUrl.trim().length > 0 && isImageUrl(taskResultUrl)
|
||
if (hasValidImageUrl) {
|
||
// 分镜图生成成功,每次轮询都更新状态(清除可能的失败状态)
|
||
taskStatus.value = currentStatus
|
||
|
||
// 设置分镜图 URL(只在变化时)
|
||
if (!generatedImageUrl.value || generatedImageUrl.value !== taskResultUrl) {
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||
mainReferenceImage.value = taskResultUrl // 同时填入左侧分镜图框
|
||
// 只有在分镜图生成阶段才设置 inProgress = false
|
||
// 如果是视频生成阶段(currentStep === 'video'),保持 inProgress = true
|
||
// 因为用户可能正在等待视频生成
|
||
if (currentStep.value !== 'video') {
|
||
inProgress.value = false
|
||
console.log('[轮询] 分镜图生成完成,设置 inProgress = false')
|
||
} else {
|
||
console.log('[轮询] 当前在视频步骤,保持 inProgress =', inProgress.value, ', progress:', taskProgress)
|
||
}
|
||
|
||
// 不再将生成的分镜图添加到参考图数组中,只显示在右侧预览区域
|
||
}
|
||
|
||
// 每次轮询都尝试填充优化后的提示词
|
||
// 填充 imagePrompt 到生图提示词框(替换用户原始输入)
|
||
if (task.imagePrompt && task.imagePrompt.trim()) {
|
||
if (inputText.value !== task.imagePrompt) {
|
||
inputText.value = task.imagePrompt
|
||
console.log('[DEBUG] 已自动填充 imagePrompt:', task.imagePrompt.substring(0, 100))
|
||
}
|
||
}
|
||
// 填充 videoPrompt 到视频提示词框
|
||
if (task.videoPrompt && task.videoPrompt.trim()) {
|
||
if (videoPrompt.value !== task.videoPrompt) {
|
||
videoPrompt.value = task.videoPrompt
|
||
console.log('[DEBUG] 已自动填充 videoPrompt:', task.videoPrompt.substring(0, 100))
|
||
}
|
||
}
|
||
|
||
// 如果进度 < 100,说明只是分镜图完成,视频还没生成
|
||
// 注意:如果正在生成视频(inProgress=true),不要停止轮询
|
||
if (taskProgress < 100 && !inProgress.value) {
|
||
const imagePromptStr = task.imagePrompt ? String(task.imagePrompt).trim() : ''
|
||
const videoPromptStr = task.videoPrompt ? String(task.videoPrompt).trim() : ''
|
||
const hasImagePrompt = !!imagePromptStr && imagePromptStr !== 'null'
|
||
const hasVideoPrompt = !!videoPromptStr && videoPromptStr !== 'null'
|
||
|
||
// 只在用户没有手动切换步骤时才自动切换
|
||
if (!userManuallyChangedStep.value) {
|
||
currentStep.value = 'video'
|
||
}
|
||
|
||
// videoPrompt 还没就绪时:先用 imagePrompt 临时填充,继续短间隔轮询等待真正 videoPrompt
|
||
if (!hasVideoPrompt) {
|
||
if (!videoPrompt.value.trim() && hasImagePrompt) {
|
||
videoPrompt.value = imagePromptStr
|
||
}
|
||
promptWaitAttempts++
|
||
console.log('[轮询] 分镜图已生成但 videoPrompt 未就绪,继续轮询', { promptWaitAttempts })
|
||
pollIntervalId.value = setTimeout(poll, 5000)
|
||
return
|
||
}
|
||
|
||
// videoPrompt 已就绪,可以停止轮询并提示
|
||
if (pollIntervalId.value) {
|
||
clearTimeout(pollIntervalId.value)
|
||
pollIntervalId.value = null
|
||
}
|
||
promptWaitAttempts = 0
|
||
|
||
setTimeout(() => {
|
||
ElMessage.success(t('video.storyboard.storyboardCompleted'))
|
||
}, 100)
|
||
return
|
||
}
|
||
// 如果进度 >= 100,说明视频也生成了,会在下面的 COMPLETED 分支处理
|
||
// 但这里先确保图片已显示
|
||
if (currentStep.value === 'generate' && !userManuallyChangedStep.value) {
|
||
currentStep.value = 'video'
|
||
}
|
||
}
|
||
|
||
// 视频生成完成:status=COMPLETED(不再依赖 progress)
|
||
if (currentStatus === 'COMPLETED') {
|
||
if (pollIntervalId.value) {
|
||
clearTimeout(pollIntervalId.value)
|
||
pollIntervalId.value = null
|
||
}
|
||
|
||
// 优先检查 videoUrls 字段(视频URL)
|
||
let videoUrl = ''
|
||
if (task.videoUrls) {
|
||
try {
|
||
// videoUrls 可能是 JSON 数组字符串
|
||
const urls = typeof task.videoUrls === 'string' ? JSON.parse(task.videoUrls) : task.videoUrls
|
||
if (Array.isArray(urls) && urls.length > 0) {
|
||
videoUrl = urls[0] // 取第一个视频URL
|
||
} else if (typeof urls === 'string') {
|
||
videoUrl = urls
|
||
}
|
||
} catch (e) {
|
||
// 如果解析失败,直接使用原值
|
||
videoUrl = task.videoUrls
|
||
}
|
||
}
|
||
|
||
// 设置视频URL
|
||
if (videoUrl) {
|
||
videoResultUrl.value = videoUrl
|
||
console.log('[轮询] 视频生成完成,videoUrl:', videoUrl.substring(0, 100))
|
||
}
|
||
|
||
// 设置分镜图URL(如果有)
|
||
if (taskResultUrl && isImageUrl(taskResultUrl)) {
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true
|
||
}
|
||
|
||
videoTaskStatus.value = 'COMPLETED'
|
||
videoProgress.value = 100
|
||
inProgress.value = false
|
||
ElMessage.success(t('video.storyboard.videoCompleted'))
|
||
// 如果当前在生成分镜图步骤且用户没有手动切换,自动切换到视频步骤
|
||
if (currentStep.value === 'generate' && !userManuallyChangedStep.value) {
|
||
currentStep.value = 'video'
|
||
}
|
||
return
|
||
}
|
||
// 视频生成中:status=PROCESSING 且 progress > 50(说明视频生成已开始)
|
||
if (currentStatus === 'PROCESSING' && taskProgress > 50) {
|
||
videoProgress.value = taskProgress
|
||
videoTaskStatus.value = 'PROCESSING'
|
||
// 如果分镜图已生成但还没显示,先显示分镜图
|
||
if (taskResultUrl && isImageUrl(taskResultUrl) && (!generatedImageUrl.value || generatedImageUrl.value !== taskResultUrl)) {
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||
if (currentStep.value === 'generate' && !userManuallyChangedStep.value) {
|
||
currentStep.value = 'video'
|
||
}
|
||
}
|
||
}
|
||
// 分镜图生成中:status=PROCESSING 且 progress < 50
|
||
// 继续等待分镜图生成
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询任务状态失败:', error)
|
||
// 错误时继续轮询,直到达到最大次数
|
||
}
|
||
|
||
// 根据当前阶段设置下一次轮询的间隔
|
||
pollIntervalId.value = setTimeout(poll, currentInterval)
|
||
}
|
||
|
||
// 开始第一次轮询
|
||
pollIntervalId.value = setTimeout(poll, currentInterval)
|
||
}
|
||
|
||
// 将图片URL转换为File对象
|
||
const urlToFile = async (url, filename) => {
|
||
try {
|
||
let blob
|
||
|
||
// 如果是base64格式
|
||
if (url.startsWith('data:image')) {
|
||
const response = await fetch(url)
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP错误: ${response.status}`)
|
||
}
|
||
blob = await response.blob()
|
||
return new File([blob], filename, { type: blob.type })
|
||
} else {
|
||
// 如果是普通URL(可能跨域)
|
||
try {
|
||
const response = await fetch(url)
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP错误: ${response.status}`)
|
||
}
|
||
blob = await response.blob()
|
||
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
||
} catch (fetchError) {
|
||
// 如果fetch失败(可能是CORS),尝试通过代理或提示用户
|
||
console.error('直接获取图片失败,可能是CORS问题:', fetchError)
|
||
throw new Error('无法加载图片,请确保图片URL可以访问')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('转换图片URL失败:', error)
|
||
throw new Error('无法加载图片: ' + (error.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
// 生成视频
|
||
const startVideoGenerate = async () => {
|
||
// 检查登录状态
|
||
if (!userStore.isAuthenticated) {
|
||
ElMessage.warning(t('video.storyboard.loginBeforeSubmit'))
|
||
goToLogin()
|
||
return
|
||
}
|
||
|
||
// 如果有分镜图任务ID(从分镜图生成),使用新的API
|
||
if (taskId.value && generatedImageUrl.value) {
|
||
try {
|
||
// 清空之前的视频结果,确保显示生成中状态
|
||
videoResultUrl.value = ''
|
||
|
||
inProgress.value = true
|
||
taskStatus.value = 'PROCESSING' // 设置 taskStatus 以便模板正确显示
|
||
videoTaskStatus.value = 'PROCESSING'
|
||
videoProgress.value = 50 // 从50%开始(分镜图已完成)
|
||
currentStep.value = 'video' // 确保切换到视频步骤
|
||
|
||
console.log('[startVideoGenerate] 状态已设置: inProgress=', inProgress.value, ', taskStatus=', taskStatus.value, ', currentStep=', currentStep.value, ', videoResultUrl=', videoResultUrl.value)
|
||
|
||
// 设置 currentTask 以便预览区域显示正确的状态
|
||
if (!currentTask.value) {
|
||
currentTask.value = {
|
||
taskId: taskId.value,
|
||
status: 'PROCESSING',
|
||
createdAt: new Date().toISOString()
|
||
}
|
||
} else {
|
||
currentTask.value.status = 'PROCESSING'
|
||
}
|
||
|
||
ElMessage.info(t('video.storyboard.startingVideoGenerate'))
|
||
|
||
// 导入 startVideoGeneration API
|
||
const { startVideoGeneration } = await import('@/api/storyboardVideo')
|
||
|
||
// 只收集视频阶段用户上传的参考图(videoReferenceImages),不使用分镜图阶段的参考图
|
||
let referenceImages = videoReferenceImages.value
|
||
.filter(img => img && img.url)
|
||
.map(img => img.url)
|
||
|
||
console.log('[生成视频] videoReferenceImages 数量:', referenceImages.length)
|
||
if (referenceImages.length > 0) {
|
||
console.log('[生成视频] 第一张参考图前50字符:', referenceImages[0].substring(0, 50))
|
||
}
|
||
|
||
const response = await startVideoGeneration(taskId.value, {
|
||
duration: parseInt(duration.value),
|
||
aspectRatio: aspectRatio.value,
|
||
hdMode: hdMode.value,
|
||
referenceImages: referenceImages // 只传递视频阶段的参考图
|
||
})
|
||
|
||
if (response.data && response.data.success) {
|
||
ElMessage.success(t('video.storyboard.videoTaskStarted'))
|
||
|
||
// 继续轮询任务状态,获取视频生成进度
|
||
// 用户主动开始生成视频,重置手动切换标志
|
||
userManuallyChangedStep.value = false
|
||
if (currentStep.value !== 'video') {
|
||
currentStep.value = 'video'
|
||
}
|
||
pollTaskStatus(taskId.value)
|
||
} else {
|
||
const errorMsg = response.data?.message || t('video.storyboard.videoStartFailed')
|
||
handleInsufficientPointsError(errorMsg, errorMsg)
|
||
inProgress.value = false
|
||
}
|
||
} catch (error) {
|
||
const errorMsg = error.response?.data?.message || error.message || ''
|
||
handleInsufficientPointsError(errorMsg, t('video.storyboard.videoStartFailed') + ': ' + errorMsg)
|
||
inProgress.value = false
|
||
}
|
||
return
|
||
}
|
||
|
||
// 如果没有分镜图任务ID,使用上传的分镜图直接生成视频
|
||
let imageUrl = mainReferenceImage.value || uploadedImage.value
|
||
|
||
if (!imageUrl) {
|
||
ElMessage.warning(t('video.storyboard.uploadOrGenerateFirst'))
|
||
return
|
||
}
|
||
|
||
// 判断是否需要拼接网格:
|
||
// 1. 只处理用户手动上传的图片(file不为null,排除AI生成的)
|
||
// 2. 用户上传了2张或以上的图片时,才拼接成网格
|
||
const userUploadedImages = uploadedImages.value.filter(img => img.file !== null)
|
||
|
||
if (userUploadedImages.length > 1) {
|
||
try {
|
||
// 根据图片数量自动决定网格布局
|
||
// 2张 → 2列 (1×2)
|
||
// 3张 → 3列 (1×3)
|
||
// 4张 → 2列 (2×2)
|
||
// 5-6张 → 3列 (2×3)
|
||
// 7-9张 → 3列 (3×3)
|
||
const cols = userUploadedImages.length <= 3 ? userUploadedImages.length : 3
|
||
const rows = Math.ceil(userUploadedImages.length / cols)
|
||
|
||
ElMessage.info(`正在将 ${userUploadedImages.length} 张图片拼接为 ${rows}×${cols} 网格...`)
|
||
|
||
const { mergeImagesToGrid } = await import('@/api/storyboardVideo')
|
||
const imageUrls = userUploadedImages.map(img => img.url)
|
||
|
||
// cols=0 表示自动计算列数
|
||
const mergeResponse = await mergeImagesToGrid(imageUrls, 0)
|
||
|
||
if (mergeResponse.data && mergeResponse.data.success) {
|
||
imageUrl = mergeResponse.data.data.mergedImage
|
||
generatedImageUrl.value = imageUrl
|
||
ElMessage.success(`已拼接 ${userUploadedImages.length} 张图片为 ${rows}×${cols} 网格`)
|
||
} else {
|
||
throw new Error(mergeResponse.data?.message || '拼接失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('拼接图片失败:', error)
|
||
ElMessage.error('拼接图片失败: ' + (error.message || '未知错误'))
|
||
inProgress.value = false
|
||
return
|
||
}
|
||
}
|
||
|
||
// 提示词:在视频步骤使用 videoPrompt,否则使用 inputText
|
||
let prompt = currentStep.value === 'video' ? videoPrompt.value.trim() : inputText.value.trim()
|
||
if (!prompt) {
|
||
prompt = t('video.storyboard.defaultPrompt') // 默认提示词
|
||
}
|
||
|
||
try {
|
||
inProgress.value = true
|
||
currentStep.value = 'video' // 确保切换到视频步骤
|
||
videoResultUrl.value = '' // 清空之前的视频结果
|
||
taskStatus.value = 'PROCESSING' // 设置任务状态
|
||
|
||
// 设置 currentTask 以便预览区域显示正确的状态
|
||
if (!currentTask.value) {
|
||
currentTask.value = {
|
||
taskId: '',
|
||
status: 'PROCESSING',
|
||
createdAt: new Date().toISOString()
|
||
}
|
||
} else {
|
||
currentTask.value.status = 'PROCESSING'
|
||
}
|
||
|
||
ElMessage.info(t('video.storyboard.startingVideoGenerate'))
|
||
|
||
const { createStoryboardTask, startVideoGeneration } = await import('@/api/storyboardVideo')
|
||
|
||
// 第一步:创建任务(传入上传的分镜图)
|
||
const response = await createStoryboardTask({
|
||
prompt: prompt || '根据图片生成分镜',
|
||
aspectRatio: aspectRatio.value,
|
||
duration: parseInt(duration.value),
|
||
hdMode: hdMode.value,
|
||
imageUrl: imageUrl // 传入上传的分镜图
|
||
})
|
||
|
||
if (response.data && response.data.success) {
|
||
const newTaskId = response.data.data.taskId
|
||
taskId.value = newTaskId
|
||
taskStatus.value = 'PROCESSING'
|
||
|
||
// 设置分镜图URL(用于显示)
|
||
generatedImageUrl.value = imageUrl
|
||
|
||
// 第二步:立即开始视频生成
|
||
const videoResponse = await startVideoGeneration(newTaskId, {
|
||
duration: parseInt(duration.value),
|
||
aspectRatio: aspectRatio.value,
|
||
hdMode: hdMode.value
|
||
})
|
||
|
||
if (videoResponse.data && videoResponse.data.success) {
|
||
ElMessage.success(t('video.storyboard.videoTaskStarted'))
|
||
refreshUserPoints()
|
||
pollTaskStatus(newTaskId)
|
||
} else {
|
||
const errorMsg = videoResponse.data?.message || t('video.storyboard.videoStartFailed')
|
||
handleInsufficientPointsError(errorMsg, errorMsg)
|
||
inProgress.value = false
|
||
}
|
||
} else {
|
||
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
|
||
handleInsufficientPointsError(errorMsg, errorMsg)
|
||
inProgress.value = false
|
||
}
|
||
} catch (error) {
|
||
const errorMsg = error.response?.data?.message || error.message || ''
|
||
handleInsufficientPointsError(errorMsg, t('video.storyboard.generateVideoFailed') + ': ' + errorMsg)
|
||
inProgress.value = false
|
||
}
|
||
}
|
||
|
||
// 轮询视频任务状态
|
||
const pollVideoTaskStatus = async (taskId) => {
|
||
// 清除之前的轮询(如果存在)
|
||
if (videoPollIntervalId.value) {
|
||
clearTimeout(videoPollIntervalId.value)
|
||
videoPollIntervalId.value = null
|
||
}
|
||
|
||
const maxAttempts = 30 // 视频任务可能需要更长时间,最多60分钟(每2分钟一次)
|
||
let attempts = 0
|
||
const pollInterval = 120000 // 2分钟轮询一次(视频生成阶段)
|
||
|
||
const poll = async () => {
|
||
// 先检查是否超过最大尝试次数
|
||
if (attempts >= maxAttempts) {
|
||
if (videoPollIntervalId.value) {
|
||
clearTimeout(videoPollIntervalId.value)
|
||
videoPollIntervalId.value = null
|
||
}
|
||
inProgress.value = false
|
||
videoTaskStatus.value = 'TIMEOUT'
|
||
ElMessage.warning(t('video.storyboard.videoTaskTimeout'))
|
||
return
|
||
}
|
||
|
||
attempts++
|
||
|
||
try {
|
||
// 调用获取分镜视频任务详情的API
|
||
const response = await getStoryboardTask(taskId)
|
||
|
||
if (response.data.success && response.data.data) {
|
||
const task = response.data.data
|
||
videoTaskStatus.value = task.status || 'PROCESSING'
|
||
videoProgress.value = task.progress || 0
|
||
|
||
if (task.status === 'COMPLETED' && task.resultUrl) {
|
||
if (videoPollIntervalId.value) {
|
||
clearTimeout(videoPollIntervalId.value)
|
||
videoPollIntervalId.value = null
|
||
}
|
||
// 如果 resultUrl 是视频URL(不是图片),更新视频URL
|
||
if (!isImageUrl(task.resultUrl)) {
|
||
videoResultUrl.value = task.resultUrl
|
||
}
|
||
inProgress.value = false
|
||
videoProgress.value = 100
|
||
ElMessage.success(t('video.storyboard.videoCompleted'))
|
||
refreshUserPoints()
|
||
return
|
||
} else if (task.status === 'FAILED' || task.status === 'CANCELLED') {
|
||
if (videoPollIntervalId.value) {
|
||
clearTimeout(videoPollIntervalId.value)
|
||
videoPollIntervalId.value = null
|
||
}
|
||
inProgress.value = false
|
||
videoTaskStatus.value = 'FAILED'
|
||
return
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询视频任务状态失败:', error)
|
||
// 错误时继续轮询,直到达到最大次数
|
||
}
|
||
|
||
// 继续下一次轮询
|
||
videoPollIntervalId.value = setTimeout(poll, pollInterval)
|
||
}
|
||
|
||
// 开始第一次轮询
|
||
videoPollIntervalId.value = setTimeout(poll, pollInterval)
|
||
}
|
||
|
||
// 处理历史记录URL
|
||
const processHistoryUrl = (url) => {
|
||
if (!url) return ''
|
||
// 如果是相对路径,确保格式正确
|
||
if (url.startsWith('/') || !url.startsWith('http')) {
|
||
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
|
||
return url.startsWith('/') ? url : `/${url}`
|
||
}
|
||
}
|
||
return url
|
||
}
|
||
|
||
// 加载历史记录(同时加载分镜图和视频两种历史记录)
|
||
const loadHistory = async () => {
|
||
// 只有登录用户才能查看历史记录
|
||
if (!userStore.isAuthenticated) {
|
||
storyboardHistoryTasks.value = []
|
||
videoHistoryTasks.value = []
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 同时加载分镜图和视频历史记录
|
||
|
||
// 1. 加载视频历史记录:从 user_works 获取 STORYBOARD_VIDEO 类型的作品
|
||
const videoResponse = await getMyWorks({ page: 0, size: 1000 })
|
||
|
||
if (videoResponse.data && videoResponse.data.success) {
|
||
const works = (videoResponse.data.data || []).filter(work =>
|
||
work.workType === 'STORYBOARD_VIDEO' && work.status === 'COMPLETED'
|
||
)
|
||
|
||
console.log(`[历史记录-视频] 结果数量: ${works.length}`)
|
||
|
||
// 转换为任务格式 - 分镜视频用 resultUrl 显示视频
|
||
videoHistoryTasks.value = works.map(work => ({
|
||
taskId: work.taskId || work.id?.toString(),
|
||
prompt: work.prompt,
|
||
resultUrl: work.resultUrl, // 视频URL(用于播放)
|
||
thumbnailUrl: work.thumbnailUrl, // 分镜图(用于缩略图)
|
||
videoUrls: work.resultUrl, // 视频URL
|
||
videoResultUrl: work.resultUrl, // 视频URL(用于下载)
|
||
imageUrl: work.imageUrl,
|
||
uploadedImages: work.uploadedImages, // 用户上传的参考图,用于做同款
|
||
imagePrompt: work.imagePrompt,
|
||
videoPrompt: work.videoPrompt,
|
||
aspectRatio: work.aspectRatio,
|
||
duration: work.duration, // 视频时长
|
||
hdMode: work.quality === 'HD', // 从quality字段转换
|
||
quality: work.quality,
|
||
status: work.status,
|
||
workType: work.workType,
|
||
createdAt: work.createdAt
|
||
}))
|
||
} else {
|
||
videoHistoryTasks.value = []
|
||
}
|
||
|
||
// 2. 加载分镜图历史记录:从 storyboard_video_tasks 获取分镜图任务
|
||
const storyboardResponse = await getUserStoryboardTasks(0, 1000)
|
||
|
||
if (storyboardResponse.data && storyboardResponse.data.success) {
|
||
// 显示有分镜图的任务
|
||
const tasks = (storyboardResponse.data.data || []).filter(task => {
|
||
if (task.status !== 'COMPLETED') return false
|
||
// 检查 resultUrl 是否存在且是图片
|
||
const resultUrl = task.resultUrl || ''
|
||
if (!resultUrl) return false
|
||
const isVideo = /\.(mp4|webm|mov|avi)(\?|$)/i.test(resultUrl)
|
||
return !isVideo // 只要不是视频就显示
|
||
})
|
||
|
||
console.log(`[历史记录-分镜图] 结果数量: ${tasks.length}`)
|
||
|
||
// 处理URL
|
||
storyboardHistoryTasks.value = tasks.map(task => ({
|
||
...task,
|
||
resultUrl: task.resultUrl && !task.resultUrl.startsWith('data:') && !task.resultUrl.startsWith('http')
|
||
? processHistoryUrl(task.resultUrl)
|
||
: task.resultUrl,
|
||
imageUrl: task.imageUrl && !task.imageUrl.startsWith('data:') && !task.imageUrl.startsWith('http')
|
||
? processHistoryUrl(task.imageUrl)
|
||
: task.imageUrl
|
||
}))
|
||
} else {
|
||
storyboardHistoryTasks.value = []
|
||
}
|
||
} catch (error) {
|
||
console.error('[历史记录] 加载失败:', error)
|
||
storyboardHistoryTasks.value = []
|
||
videoHistoryTasks.value = []
|
||
}
|
||
}
|
||
|
||
// 从历史记录创建同款
|
||
const createSimilarFromHistory = (task) => {
|
||
// 判断是分镜视频还是分镜图
|
||
// 分镜视频:有 videoUrls 或 videoResultUrl(视频URL)
|
||
// 分镜图:只有 resultUrl 且是图片URL,没有视频
|
||
const hasVideoResult = task.videoUrls || task.videoResultUrl
|
||
const isStoryboardVideo = hasVideoResult
|
||
|
||
console.log('[做同款] 任务类型:', isStoryboardVideo ? '分镜视频' : '分镜图', 'hasVideoResult:', hasVideoResult, task)
|
||
|
||
// 重置状态
|
||
videoResultUrl.value = ''
|
||
taskStatus.value = ''
|
||
|
||
if (isStoryboardVideo) {
|
||
// === 分镜视频做同款 ===
|
||
// 填充:分镜图 + 用户上传参考图 + videoPrompt
|
||
// 直接进入 Step 2(生成视频)
|
||
currentStep.value = 'video'
|
||
taskId.value = task.taskId || ''
|
||
|
||
// 恢复分镜图(显示在左侧分镜图框)- 使用 thumbnailUrl(分镜图)
|
||
const storyboardImageUrl = task.thumbnailUrl || (isImageUrl(task.resultUrl) ? task.resultUrl : null)
|
||
if (storyboardImageUrl) {
|
||
mainReferenceImage.value = storyboardImageUrl
|
||
generatedImageUrl.value = storyboardImageUrl // 同时设置右侧预览
|
||
console.log('[做同款-视频] 恢复分镜图:', storyboardImageUrl)
|
||
} else {
|
||
mainReferenceImage.value = ''
|
||
generatedImageUrl.value = ''
|
||
}
|
||
|
||
// 恢复 videoPrompt 到视频提示词输入框
|
||
// 如果没有 videoPrompt,使用 prompt 作为默认值
|
||
if (task.videoPrompt) {
|
||
videoPrompt.value = task.videoPrompt
|
||
console.log('[做同款-视频] 恢复 videoPrompt:', task.videoPrompt.substring(0, 100))
|
||
} else if (task.prompt) {
|
||
videoPrompt.value = task.prompt
|
||
console.log('[做同款-视频] 无 videoPrompt,使用 prompt:', task.prompt.substring(0, 100))
|
||
} else {
|
||
videoPrompt.value = ''
|
||
}
|
||
|
||
// 清空 Step 1 的 inputText,避免混淆
|
||
inputText.value = ''
|
||
|
||
// 恢复用户上传的参考图片(显示在下方3个上传框中)
|
||
videoReferenceImages.value = [null, null, null] // 先清空
|
||
// 优先从 uploadedImages 恢复(JSON数组)
|
||
if (task.uploadedImages) {
|
||
try {
|
||
const parsedImages = typeof task.uploadedImages === 'string'
|
||
? JSON.parse(task.uploadedImages)
|
||
: task.uploadedImages
|
||
if (Array.isArray(parsedImages)) {
|
||
parsedImages.filter(img => img && img !== 'null').forEach((img, idx) => {
|
||
if (idx < 3) {
|
||
videoReferenceImages.value[idx] = {
|
||
url: img,
|
||
file: null,
|
||
name: `参考图片${idx + 1}`
|
||
}
|
||
}
|
||
})
|
||
console.log('[做同款-视频] 恢复用户上传图片(uploadedImages):', parsedImages.length, '张')
|
||
}
|
||
} catch (e) {
|
||
console.warn('[做同款-视频] 解析 uploadedImages 失败:', e)
|
||
}
|
||
}
|
||
// 兼容旧字段 imageUrl
|
||
if (!videoReferenceImages.value[0] && task.imageUrl) {
|
||
videoReferenceImages.value[0] = {
|
||
url: task.imageUrl,
|
||
file: null,
|
||
name: '参考图片'
|
||
}
|
||
console.log('[做同款-视频] 恢复用户上传图片(imageUrl):', task.imageUrl)
|
||
}
|
||
|
||
// 清空 Step 1 的参考图
|
||
uploadedImages.value = []
|
||
|
||
} else {
|
||
// === 分镜图做同款 ===
|
||
// 填充:imagePrompt + 用户上传参考图
|
||
// 进入 Step 1(生成分镜图)
|
||
currentStep.value = 'generate'
|
||
generatedImageUrl.value = ''
|
||
taskId.value = ''
|
||
|
||
// 恢复 imagePrompt(优先)或 prompt 到提示词输入框
|
||
if (task.imagePrompt) {
|
||
inputText.value = task.imagePrompt
|
||
console.log('[做同款-图] 恢复 imagePrompt:', task.imagePrompt.substring(0, 100))
|
||
} else if (task.prompt) {
|
||
inputText.value = task.prompt
|
||
console.log('[做同款-图] 恢复 prompt:', task.prompt.substring(0, 100))
|
||
}
|
||
|
||
// 恢复用户上传的参考图片到 Step 1 的参考图框
|
||
uploadedImages.value = []
|
||
// 优先从 uploadedImages 恢复(JSON数组)
|
||
if (task.uploadedImages) {
|
||
try {
|
||
const parsedImages = typeof task.uploadedImages === 'string'
|
||
? JSON.parse(task.uploadedImages)
|
||
: task.uploadedImages
|
||
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
|
||
uploadedImages.value = parsedImages.filter(img => img && img !== 'null').map((img, idx) => ({
|
||
url: img,
|
||
file: null,
|
||
name: `参考图片${idx + 1}`
|
||
}))
|
||
console.log('[做同款-图] 恢复用户上传图片(uploadedImages):', uploadedImages.value.length, '张')
|
||
}
|
||
} catch (e) {
|
||
console.warn('[做同款-图] 解析 uploadedImages 失败:', e)
|
||
}
|
||
}
|
||
// 兼容旧字段 imageUrl
|
||
if (uploadedImages.value.length === 0 && task.imageUrl) {
|
||
uploadedImages.value = [{
|
||
url: task.imageUrl,
|
||
file: null,
|
||
name: '参考图片'
|
||
}]
|
||
console.log('[做同款-图] 恢复用户上传图片(imageUrl):', task.imageUrl)
|
||
}
|
||
}
|
||
|
||
// 恢复宽高比、高清模式和时长
|
||
if (task.aspectRatio) {
|
||
aspectRatio.value = task.aspectRatio
|
||
}
|
||
if (task.hdMode !== undefined) {
|
||
hdMode.value = task.hdMode
|
||
}
|
||
// 恢复时长(从 "10s" 格式中提取数字)
|
||
if (task.duration) {
|
||
const durationNum = parseInt(task.duration)
|
||
if (!isNaN(durationNum)) {
|
||
duration.value = String(durationNum)
|
||
console.log('[做同款] 恢复时长:', durationNum)
|
||
}
|
||
}
|
||
|
||
// 滚动到顶部
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
ElMessage.success(t('video.storyboard.paramsFilled'))
|
||
}
|
||
|
||
// 从分镜图历史记录跳转到视频生成步骤
|
||
const goToVideoStepWithStoryboard = (task) => {
|
||
console.log('[生成视频] 从分镜图历史记录跳转:', task)
|
||
|
||
// 切换到视频生成步骤
|
||
currentStep.value = 'video'
|
||
|
||
// 设置分镜图
|
||
if (task.resultUrl && isImageUrl(task.resultUrl)) {
|
||
generatedImageUrl.value = task.resultUrl
|
||
mainReferenceImage.value = task.resultUrl
|
||
isAIGeneratedImage.value = true
|
||
console.log('[生成视频] 设置分镜图:', task.resultUrl)
|
||
}
|
||
|
||
// 设置 videoPrompt(优先使用 videoPrompt,其次使用 imagePrompt,最后使用 prompt)
|
||
if (task.videoPrompt) {
|
||
videoPrompt.value = task.videoPrompt
|
||
} else if (task.imagePrompt) {
|
||
videoPrompt.value = task.imagePrompt
|
||
} else if (task.prompt) {
|
||
videoPrompt.value = task.prompt
|
||
}
|
||
|
||
// 恢复用户上传的参考图片到视频参考图框
|
||
videoReferenceImages.value = [null, null, null]
|
||
if (task.uploadedImages) {
|
||
try {
|
||
const parsedImages = typeof task.uploadedImages === 'string'
|
||
? JSON.parse(task.uploadedImages)
|
||
: task.uploadedImages
|
||
if (Array.isArray(parsedImages)) {
|
||
parsedImages.filter(img => img && img !== 'null').forEach((img, idx) => {
|
||
if (idx < 3) {
|
||
videoReferenceImages.value[idx] = {
|
||
url: img,
|
||
file: null,
|
||
name: `参考图片${idx + 1}`
|
||
}
|
||
}
|
||
})
|
||
console.log('[生成视频] 恢复用户上传图片:', parsedImages.length, '张')
|
||
}
|
||
} catch (e) {
|
||
console.warn('[生成视频] 解析 uploadedImages 失败:', e)
|
||
}
|
||
}
|
||
|
||
// 恢复宽高比、高清模式和时长
|
||
if (task.aspectRatio) {
|
||
aspectRatio.value = task.aspectRatio
|
||
}
|
||
if (task.hdMode !== undefined) {
|
||
hdMode.value = task.hdMode
|
||
}
|
||
if (task.duration) {
|
||
const durationNum = parseInt(task.duration)
|
||
if (!isNaN(durationNum)) {
|
||
duration.value = String(durationNum)
|
||
}
|
||
}
|
||
|
||
// 滚动到顶部
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
ElMessage.success(t('video.storyboard.readyToGenerateVideo'))
|
||
}
|
||
|
||
// 取消任务
|
||
const cancelTask = async (taskId) => {
|
||
try {
|
||
ElMessage.info(t('video.storyboard.cancelFeaturePending'))
|
||
// TODO: 实现取消任务API
|
||
} catch (error) {
|
||
console.error('取消任务失败:', error)
|
||
}
|
||
}
|
||
|
||
// 处理视频加载
|
||
const handleVideoLoaded = (event) => {
|
||
// 视频元数据加载完成,可以显示缩略图
|
||
const video = event.target
|
||
video.currentTime = 1 // 跳转到第1秒作为缩略图
|
||
// 监听播放状态
|
||
video.addEventListener('play', () => {
|
||
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
|
||
if (taskId) {
|
||
playingVideos.value[taskId] = true
|
||
}
|
||
})
|
||
video.addEventListener('pause', () => {
|
||
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
|
||
if (taskId) {
|
||
playingVideos.value[taskId] = false
|
||
}
|
||
})
|
||
video.addEventListener('ended', () => {
|
||
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
|
||
if (taskId) {
|
||
playingVideos.value[taskId] = false
|
||
video.currentTime = 1 // 播放结束后回到第1秒作为缩略图
|
||
}
|
||
})
|
||
}
|
||
|
||
// 处理视频加载错误
|
||
const handleVideoError = (event) => {
|
||
console.error('历史记录视频加载失败:', event.target.src)
|
||
// 可以在这里添加错误处理逻辑,比如显示占位图
|
||
}
|
||
|
||
// 处理图片加载错误
|
||
const handleImageError = (event) => {
|
||
console.error('历史记录图片加载失败:', event.target.src)
|
||
// 隐藏破损图标,显示占位符
|
||
event.target.style.display = 'none'
|
||
}
|
||
|
||
// 设置视频引用
|
||
const setVideoRef = (taskId, el) => {
|
||
if (el) {
|
||
videoRefs.value[taskId] = el
|
||
}
|
||
}
|
||
|
||
// 切换历史记录视频播放
|
||
const toggleHistoryVideo = (task) => {
|
||
const video = videoRefs.value[task.taskId]
|
||
if (!video) return
|
||
|
||
if (playingVideos.value[task.taskId]) {
|
||
video.pause()
|
||
playingVideos.value[task.taskId] = false
|
||
} else {
|
||
// 解除静音并设置音量,确保有声音
|
||
try {
|
||
video.muted = false
|
||
video.volume = 1
|
||
video.play()
|
||
} catch (_) {
|
||
video.play()
|
||
}
|
||
playingVideos.value[task.taskId] = true
|
||
}
|
||
}
|
||
|
||
// 格式化日期
|
||
const formatDate = (dateString) => {
|
||
if (!dateString) return ''
|
||
const date = new Date(dateString)
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
const hours = String(date.getHours()).padStart(2, '0')
|
||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||
return `${year}年${month}月${day}日 ${hours}:${minutes}`
|
||
}
|
||
|
||
// 获取状态文本
|
||
const getStatusText = (status) => {
|
||
const statusMap = {
|
||
'PENDING': t('video.storyboard.statusPending'),
|
||
'PROCESSING': t('video.storyboard.statusProcessing'),
|
||
'COMPLETED': t('video.completed'),
|
||
'FAILED': t('video.failed'),
|
||
'CANCELLED': t('video.storyboard.statusCancelled')
|
||
}
|
||
return statusMap[status] || t('video.storyboard.statusUnknown')
|
||
}
|
||
|
||
// 获取显示状态文本(根据实际内容判断,而不是只依赖taskStatus)
|
||
const getDisplayStatusText = () => {
|
||
// 分镜图创作页面
|
||
if (currentStep.value === 'generate') {
|
||
if (inProgress.value) {
|
||
return taskStatus.value === 'PENDING' ? t('video.storyboard.statusPending') : t('video.storyboard.statusProcessing')
|
||
}
|
||
if (generatedImageUrl.value && isAIGeneratedImage.value) {
|
||
return t('video.storyboard.statusCompleted') // AI生成的分镜图已完成
|
||
}
|
||
if (taskStatus.value === 'FAILED') {
|
||
return t('video.failed')
|
||
}
|
||
return t('video.storyboard.startCreating')
|
||
}
|
||
|
||
// 分镜视频创作页面
|
||
if (currentStep.value === 'video') {
|
||
if (videoResultUrl.value) {
|
||
return t('video.completed') // 视频已生成
|
||
}
|
||
if (inProgress.value) {
|
||
return t('video.storyboard.statusProcessing') // 视频生成中
|
||
}
|
||
if (taskStatus.value === 'FAILED' && !generatedImageUrl.value) {
|
||
return t('video.failed') // 只有在没有分镜图时才显示失败
|
||
}
|
||
if (generatedImageUrl.value) {
|
||
return t('video.storyboard.storyboardReady') // 分镜图已就绪,等待生成视频
|
||
}
|
||
return t('video.storyboard.uploadStoryboardFirst')
|
||
}
|
||
|
||
return getStatusText(taskStatus.value)
|
||
}
|
||
|
||
// 下载视频
|
||
const downloadVideo = async () => {
|
||
if (!videoResultUrl.value) {
|
||
ElMessage.error(t('video.storyboard.videoUrlNotAvailable'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在准备下载...')
|
||
const { downloadVideo: downloadVideoUtil } = await import('@/utils/download')
|
||
const success = await downloadVideoUtil(videoResultUrl.value, taskId.value || Date.now())
|
||
if (success) {
|
||
ElMessage.success(t('video.storyboard.downloadStarted'))
|
||
}
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
window.open(videoResultUrl.value, '_blank')
|
||
}
|
||
}
|
||
|
||
// 下载历史记录中的视频
|
||
const downloadHistoryVideo = async (task) => {
|
||
if (!task || !task.videoResultUrl) {
|
||
ElMessage.error(t('video.storyboard.videoUrlNotAvailable'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在准备下载...')
|
||
const { downloadVideo: downloadVideoUtil } = await import('@/utils/download')
|
||
const success = await downloadVideoUtil(task.videoResultUrl, task.taskId || Date.now())
|
||
if (success) {
|
||
ElMessage.success(t('video.storyboard.downloadStarted'))
|
||
}
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
window.open(task.videoResultUrl, '_blank')
|
||
}
|
||
}
|
||
|
||
// 下载历史记录中的分镜图
|
||
const downloadHistoryImage = async (task) => {
|
||
if (!task || !task.resultUrl) {
|
||
ElMessage.error(t('video.storyboard.imageUrlNotAvailable'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在准备下载...')
|
||
const { downloadImage } = await import('@/utils/download')
|
||
const success = await downloadImage(task.resultUrl, task.taskId || Date.now(), 'storyboard')
|
||
if (success) {
|
||
ElMessage.success(t('video.storyboard.downloadStarted'))
|
||
}
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
window.open(task.resultUrl, '_blank')
|
||
}
|
||
}
|
||
|
||
// 组件挂载时加载历史记录
|
||
// 监听登录状态变化,登录后自动加载历史记录
|
||
watch(() => userStore.isAuthenticated, (isAuth) => {
|
||
if (isAuth) {
|
||
loadHistory()
|
||
// 延迟恢复任务,避免与创建任务冲突
|
||
setTimeout(() => {
|
||
if (!isCreatingTask.value) {
|
||
restoreProcessingTask()
|
||
}
|
||
}, 500)
|
||
} else {
|
||
storyboardHistoryTasks.value = []
|
||
videoHistoryTasks.value = []
|
||
hasRestoredTask.value = false
|
||
}
|
||
})
|
||
|
||
// 监听步骤变化(历史记录已分开存储,不需要重新加载)
|
||
watch(() => currentStep.value, (newStep, oldStep) => {
|
||
console.log('[步骤变化] 从', oldStep, '切换到:', newStep)
|
||
})
|
||
|
||
// 恢复正在进行中的任务
|
||
const restoreProcessingTask = async () => {
|
||
if (!userStore.isAuthenticated) {
|
||
return false
|
||
}
|
||
|
||
// 如果正在创建任务,跳过恢复逻辑
|
||
if (isCreatingTask.value) {
|
||
return false
|
||
}
|
||
|
||
// 如果已经恢复过任务且当前有任务在进行中,跳过
|
||
if (hasRestoredTask.value && currentTask.value) {
|
||
return true
|
||
}
|
||
|
||
try {
|
||
const response = await getProcessingWorks()
|
||
if (response.data && response.data.success && response.data.data) {
|
||
const works = response.data.data
|
||
// 只恢复分镜视频类型的任务(包括 PROCESSING 和 PENDING 状态)
|
||
const storyboardWorks = works.filter(work => work.workType === 'STORYBOARD_VIDEO')
|
||
|
||
if (storyboardWorks.length > 0) {
|
||
// 取最新的一个任务
|
||
const work = storyboardWorks[0]
|
||
|
||
// 获取任务详情以准确恢复状态
|
||
try {
|
||
const detailResponse = await getStoryboardTask(work.taskId)
|
||
if (detailResponse.data && detailResponse.data.success) {
|
||
const taskDetail = detailResponse.data.data
|
||
|
||
currentTask.value = taskDetail
|
||
taskId.value = taskDetail.taskId
|
||
|
||
// 恢复输入参数
|
||
if (taskDetail.prompt) {
|
||
inputText.value = taskDetail.prompt
|
||
}
|
||
if (taskDetail.aspectRatio) {
|
||
aspectRatio.value = taskDetail.aspectRatio
|
||
}
|
||
if (taskDetail.duration) {
|
||
duration.value = taskDetail.duration || '10'
|
||
}
|
||
if (taskDetail.hdMode !== undefined) {
|
||
hdMode.value = taskDetail.hdMode
|
||
}
|
||
|
||
// 标记已恢复任务
|
||
hasRestoredTask.value = true
|
||
|
||
// 判断任务进度,决定恢复到哪个步骤
|
||
const taskProgress = Number(taskDetail.progress) || 0
|
||
const taskResultUrl = taskDetail.resultUrl || ''
|
||
const detailStatus = taskDetail.status || 'PROCESSING'
|
||
|
||
// 1. 如果分镜图生成任务已完成(有resultUrl且状态是COMPLETED),不应该恢复
|
||
if (taskResultUrl && isImageUrl(taskResultUrl) && detailStatus === 'COMPLETED') {
|
||
// 设置分镜图URL,但不启动任务
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
|
||
|
||
// 恢复 videoPrompt
|
||
if (taskDetail.videoPrompt) {
|
||
videoPrompt.value = taskDetail.videoPrompt
|
||
} else if (taskDetail.prompt) {
|
||
videoPrompt.value = taskDetail.prompt
|
||
}
|
||
|
||
// 将分镜图添加到上传列表
|
||
const alreadyInList = uploadedImages.value.some(img => img.url === taskResultUrl)
|
||
if (!alreadyInList) {
|
||
uploadedImages.value.unshift({
|
||
url: taskResultUrl,
|
||
file: null,
|
||
name: '生成的分镜图'
|
||
})
|
||
}
|
||
currentStep.value = 'video' // 切换到视频生成步骤
|
||
inProgress.value = false // 不显示"生成中"
|
||
console.log('[恢复任务] 分镜图已完成,填充分镜图框和videoPrompt')
|
||
return false // 不需要恢复轮询
|
||
}
|
||
|
||
// 2. 如果分镜图已生成(有resultUrl),状态是PROCESSING
|
||
// 说明视频正在生成中(分镜图完成后,状态仍为PROCESSING表示视频生成中)
|
||
if (taskResultUrl && isImageUrl(taskResultUrl) && detailStatus === 'PROCESSING') {
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
|
||
|
||
// 恢复 videoPrompt
|
||
if (taskDetail.videoPrompt) {
|
||
videoPrompt.value = taskDetail.videoPrompt
|
||
} else if (taskDetail.prompt) {
|
||
videoPrompt.value = taskDetail.prompt
|
||
}
|
||
|
||
// 将分镜图添加到上传列表
|
||
const alreadyInList = uploadedImages.value.some(img => img.url === taskResultUrl)
|
||
if (!alreadyInList) {
|
||
uploadedImages.value.unshift({
|
||
url: taskResultUrl,
|
||
file: null,
|
||
name: '生成的分镜图'
|
||
})
|
||
}
|
||
|
||
// 恢复参考图片到视频参考图框
|
||
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||
videoReferenceImages.value[0] = {
|
||
url: taskDetail.imageUrl,
|
||
file: null,
|
||
name: '参考图片'
|
||
}
|
||
}
|
||
|
||
currentStep.value = 'video' // 切换到视频生成步骤
|
||
console.log('[恢复任务] 分镜图已生成,状态PROCESSING,视频正在生成中')
|
||
|
||
// 分镜图已生成 + 状态PROCESSING = 视频正在生成中
|
||
// 不再依赖进度判断,因为视频开始生成时进度就是50%
|
||
inProgress.value = true
|
||
taskStatus.value = detailStatus
|
||
currentTask.value = taskDetail // 设置currentTask以便显示任务状态
|
||
pollTaskStatus(taskDetail.taskId)
|
||
return true
|
||
}
|
||
// 3. 如果分镜图还在生成中(没有resultUrl,状态是PROCESSING)
|
||
else if (!taskResultUrl && detailStatus === 'PROCESSING') {
|
||
// 恢复用户上传的参考图片
|
||
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||
uploadedImages.value = [{
|
||
url: taskDetail.imageUrl,
|
||
file: null,
|
||
name: '参考图片'
|
||
}]
|
||
}
|
||
|
||
currentStep.value = 'generate'
|
||
inProgress.value = true
|
||
taskStatus.value = detailStatus
|
||
pollTaskStatus(taskDetail.taskId)
|
||
return true
|
||
}
|
||
// 4. 其他情况(PENDING等),也恢复
|
||
else if (detailStatus === 'PENDING') {
|
||
// 恢复用户上传的参考图片
|
||
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||
uploadedImages.value = [{
|
||
url: taskDetail.imageUrl,
|
||
file: null,
|
||
name: '参考图片'
|
||
}]
|
||
}
|
||
|
||
currentStep.value = taskResultUrl ? 'video' : 'generate'
|
||
inProgress.value = true
|
||
taskStatus.value = detailStatus
|
||
pollTaskStatus(taskDetail.taskId)
|
||
return true
|
||
}
|
||
// 5. 如果任务失败,不恢复,只在历史记录中显示
|
||
else if (detailStatus === 'FAILED') {
|
||
// 重置状态,让用户可以创建新任务
|
||
currentTask.value = null
|
||
taskId.value = ''
|
||
hasRestoredTask.value = false
|
||
inProgress.value = false
|
||
// 失败任务会在历史记录中显示,用户可以点击"做同款"重试
|
||
return false
|
||
}
|
||
// 6. 兜底:如果没有匹配任何条件,也重置状态
|
||
else {
|
||
currentTask.value = null
|
||
taskId.value = ''
|
||
hasRestoredTask.value = false
|
||
inProgress.value = false
|
||
return false
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取任务详情失败:', error)
|
||
// 如果获取详情失败,使用 work 的基本信息,但只恢复进行中的任务
|
||
const workStatus = work.status || 'PROCESSING'
|
||
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
|
||
taskId.value = work.taskId
|
||
inputText.value = work.prompt || ''
|
||
|
||
// 恢复参考图片(从 thumbnailUrl 获取)
|
||
if (work.thumbnailUrl && isImageUrl(work.thumbnailUrl)) {
|
||
uploadedImages.value = [{
|
||
url: work.thumbnailUrl,
|
||
file: null,
|
||
name: '参考图片'
|
||
}]
|
||
}
|
||
|
||
inProgress.value = true
|
||
taskStatus.value = workStatus
|
||
pollTaskStatus(work.taskId)
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('恢复任务失败:', error)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 检查最近一条任务的状态(如果失败则显示失败状态和参考图,如果分镜图已完成则恢复)
|
||
const checkLastTaskStatus = async () => {
|
||
if (!userStore.isAuthenticated) return
|
||
|
||
try {
|
||
const response = await getUserStoryboardTasks(0, 1)
|
||
if (response.data && response.data.success && response.data.data && response.data.data.length > 0) {
|
||
const lastTask = response.data.data[0]
|
||
|
||
// 检查是否是"分镜图已完成但视频未生成"的任务
|
||
// 条件:状态是 COMPLETED,有分镜图结果(resultUrl),但没有视频结果(videoUrls)
|
||
const hasStoryboard = lastTask.resultUrl && isImageUrl(lastTask.resultUrl)
|
||
const hasVideo = lastTask.videoUrls || lastTask.videoUrl
|
||
if (lastTask.status === 'COMPLETED' && hasStoryboard && !hasVideo) {
|
||
// 这是分镜图已完成的任务,恢复到 Step 2
|
||
const taskResultUrl = processHistoryUrl(lastTask.resultUrl)
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
|
||
|
||
// 恢复 taskId(关键!用于生成视频时识别任务)
|
||
taskId.value = lastTask.taskId || ''
|
||
|
||
// 恢复 videoPrompt
|
||
if (lastTask.videoPrompt) {
|
||
videoPrompt.value = lastTask.videoPrompt
|
||
} else if (lastTask.prompt) {
|
||
videoPrompt.value = lastTask.prompt
|
||
}
|
||
|
||
// 恢复提示词
|
||
if (lastTask.prompt) {
|
||
inputText.value = lastTask.prompt
|
||
}
|
||
|
||
// 恢复其他参数
|
||
if (lastTask.aspectRatio) {
|
||
aspectRatio.value = lastTask.aspectRatio
|
||
}
|
||
if (lastTask.hdMode !== undefined) {
|
||
hdMode.value = lastTask.hdMode
|
||
}
|
||
|
||
// 切换到视频生成步骤
|
||
currentStep.value = 'video'
|
||
console.log('[恢复任务] 分镜图已完成(从历史记录),taskId:', lastTask.taskId, '切换到视频生成步骤')
|
||
return
|
||
}
|
||
|
||
// 关注 FAILED 状态,恢复对应的输入参数(但不显示"重新生成"按钮)
|
||
if (lastTask.status === 'FAILED') {
|
||
currentTask.value = lastTask
|
||
// 不设置 taskStatus 和 taskId,让用户创建新任务
|
||
// 失败的任务参数会被恢复,但用户需要点击"开始生成"创建新任务
|
||
|
||
// 判断是哪个阶段失败
|
||
// 如果有 resultUrl(分镜图已生成),说明是视频生成阶段失败
|
||
const isVideoStageFailed = lastTask.resultUrl && isImageUrl(lastTask.resultUrl)
|
||
|
||
if (isVideoStageFailed) {
|
||
// === 视频生成阶段失败 ===
|
||
console.log('[恢复任务] 视频生成阶段失败,恢复 Step 2 参数')
|
||
currentStep.value = 'video'
|
||
|
||
// 恢复分镜图
|
||
const taskResultUrl = processHistoryUrl(lastTask.resultUrl)
|
||
generatedImageUrl.value = taskResultUrl
|
||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||
mainReferenceImage.value = taskResultUrl
|
||
|
||
// 恢复提示词
|
||
if (lastTask.prompt) inputText.value = lastTask.prompt
|
||
if (lastTask.videoPrompt) videoPrompt.value = lastTask.videoPrompt
|
||
else if (lastTask.prompt) videoPrompt.value = lastTask.prompt
|
||
|
||
// 恢复视频参考图 (如果后端有存储,目前假设存储在 uploadedImages 或需要扩展字段,这里暂时不做假设)
|
||
// 如果有 uploadedImages 且在视频阶段,可能是视频参考图
|
||
|
||
} else {
|
||
// === 分镜图生成阶段失败 ===
|
||
console.log('[恢复任务] 分镜图生成阶段失败,恢复 Step 1 参数')
|
||
currentStep.value = 'generate'
|
||
|
||
// 恢复提示词
|
||
if (lastTask.prompt) {
|
||
inputText.value = lastTask.prompt
|
||
}
|
||
|
||
// 恢复参考图 (uploadedImages)
|
||
if (lastTask.uploadedImages) {
|
||
try {
|
||
// 假设 uploadedImages 是 JSON 字符串或数组
|
||
let images = []
|
||
if (Array.isArray(lastTask.uploadedImages)) {
|
||
images = lastTask.uploadedImages
|
||
} else if (typeof lastTask.uploadedImages === 'string') {
|
||
// 尝试解析 JSON,如果不是 JSON 可能是逗号分隔的字符串
|
||
if (lastTask.uploadedImages.startsWith('[')) {
|
||
images = JSON.parse(lastTask.uploadedImages)
|
||
} else {
|
||
images = lastTask.uploadedImages.split(',')
|
||
}
|
||
}
|
||
|
||
if (images && images.length > 0) {
|
||
uploadedImages.value = images.map(url => ({
|
||
url: processHistoryUrl(url),
|
||
file: null, // 历史记录没有文件对象
|
||
name: '历史参考图'
|
||
}))
|
||
}
|
||
} catch (e) {
|
||
console.error('解析历史参考图失败:', e)
|
||
}
|
||
} else if (lastTask.imageUrl) {
|
||
// 兼容旧字段
|
||
uploadedImages.value = [{
|
||
url: processHistoryUrl(lastTask.imageUrl),
|
||
file: null,
|
||
name: '历史参考图'
|
||
}]
|
||
}
|
||
}
|
||
|
||
// 恢复通用参数
|
||
if (lastTask.aspectRatio) {
|
||
aspectRatio.value = lastTask.aspectRatio
|
||
}
|
||
if (lastTask.hdMode !== undefined) {
|
||
hdMode.value = lastTask.hdMode
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Check last task status error', error)
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 强制刷新用户信息,确保获取管理员修改后的最新数据
|
||
await userStore.fetchCurrentUser()
|
||
|
||
// 处理"做同款"传递的路由参数
|
||
if (route.query.prompt || route.query.referenceImage || route.query.uploadedImages || route.query.step || route.query.imagePrompt || route.query.storyboardImage) {
|
||
console.log('[做同款] 接收参数:', route.query)
|
||
|
||
// 根据 step 参数决定进入哪个步骤
|
||
if (route.query.step === 'image') {
|
||
// 分镜图做同款:进入 Step 1(生成分镜图)
|
||
currentStep.value = 'generate'
|
||
generatedImageUrl.value = ''
|
||
taskId.value = ''
|
||
|
||
// 优先使用 imagePrompt,其次使用 prompt
|
||
if (route.query.imagePrompt) {
|
||
inputText.value = route.query.imagePrompt
|
||
console.log('[做同款-图] 设置 imagePrompt:', route.query.imagePrompt.substring(0, 100))
|
||
} else if (route.query.prompt) {
|
||
inputText.value = route.query.prompt
|
||
console.log('[做同款-图] 设置 prompt:', route.query.prompt.substring(0, 100))
|
||
}
|
||
} else if (route.query.step === 'video') {
|
||
// 分镜视频做同款:进入 Step 2(生成视频)
|
||
currentStep.value = 'video'
|
||
|
||
if (route.query.prompt) {
|
||
videoPrompt.value = route.query.prompt
|
||
console.log('[做同款-视频] 设置 videoPrompt:', route.query.prompt.substring(0, 100))
|
||
}
|
||
|
||
// 设置已生成的分镜图
|
||
if (route.query.storyboardImage) {
|
||
generatedImageUrl.value = route.query.storyboardImage
|
||
mainReferenceImage.value = route.query.storyboardImage
|
||
isAIGeneratedImage.value = true
|
||
console.log('[做同款-视频] 设置分镜图:', route.query.storyboardImage)
|
||
}
|
||
} else {
|
||
// 默认行为:进入 Step 1
|
||
if (route.query.prompt) {
|
||
inputText.value = route.query.prompt
|
||
}
|
||
}
|
||
|
||
if (route.query.aspectRatio) {
|
||
aspectRatio.value = route.query.aspectRatio
|
||
}
|
||
if (route.query.duration) {
|
||
// 从 "10s" 格式中提取数字
|
||
const durationNum = parseInt(route.query.duration)
|
||
if (!isNaN(durationNum)) {
|
||
duration.value = String(durationNum)
|
||
} else {
|
||
duration.value = route.query.duration
|
||
}
|
||
}
|
||
if (route.query.hdMode) {
|
||
hdMode.value = route.query.hdMode === 'true'
|
||
}
|
||
|
||
// 处理用户上传的参考图(uploadedImages 现在是 COS URL 的 JSON 数组)
|
||
if (route.query.uploadedImages) {
|
||
try {
|
||
const parsedImages = typeof route.query.uploadedImages === 'string'
|
||
? JSON.parse(route.query.uploadedImages)
|
||
: route.query.uploadedImages
|
||
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
|
||
const imageObjects = parsedImages
|
||
.filter(img => img && img !== 'null')
|
||
.map((url, idx) => ({
|
||
url: url,
|
||
file: null,
|
||
name: `参考图片${idx + 1}`
|
||
}))
|
||
|
||
// 根据当前步骤决定填充到哪个参考图数组
|
||
if (currentStep.value === 'video') {
|
||
// Step 2:填充到视频参考图
|
||
videoReferenceImages.value = [null, null, null]
|
||
imageObjects.forEach((img, idx) => {
|
||
if (idx < 3) {
|
||
videoReferenceImages.value[idx] = img
|
||
}
|
||
})
|
||
console.log('[做同款-视频] 恢复用户上传图片:', imageObjects.length, '张')
|
||
} else {
|
||
// Step 1:填充到分镜图参考图
|
||
uploadedImages.value = imageObjects
|
||
console.log('[做同款-图] 恢复用户上传图片:', imageObjects.length, '张')
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[做同款] 解析 uploadedImages 失败:', e)
|
||
}
|
||
} else if (route.query.referenceImage) {
|
||
// 兼容旧逻辑(图生视频等)
|
||
uploadedImages.value = [{
|
||
url: route.query.referenceImage,
|
||
file: null,
|
||
name: '参考图片'
|
||
}]
|
||
console.log('[做同款] 设置参考图:', route.query.referenceImage)
|
||
}
|
||
|
||
// 静默填充,不显示弹窗提示(减少干扰)
|
||
// 清除URL中的query参数,避免刷新页面重复填充
|
||
router.replace({ path: route.path })
|
||
|
||
// 标记为从"做同款"进入,后续跳过任务恢复逻辑
|
||
isFromCreateSimilar = true
|
||
}
|
||
|
||
loadHistory()
|
||
// 延迟恢复任务,避免与创建任务冲突
|
||
// 如果是从"做同款"进入,跳过恢复逻辑(让用户使用填充的参数重新创建)
|
||
setTimeout(async () => {
|
||
if (!isCreatingTask.value && !isFromCreateSimilar) {
|
||
const restored = await restoreProcessingTask()
|
||
// 如果没有恢复进行中的任务,则检查最近一条是否失败
|
||
if (!restored) {
|
||
checkLastTaskStatus()
|
||
}
|
||
// 恢复任务后,根据最终的 currentStep 重新加载历史记录
|
||
loadHistory()
|
||
}
|
||
}, 500)
|
||
})
|
||
|
||
// 组件卸载时清理轮询
|
||
onBeforeUnmount(() => {
|
||
if (pollIntervalId.value) {
|
||
clearTimeout(pollIntervalId.value)
|
||
pollIntervalId.value = null
|
||
}
|
||
if (videoPollIntervalId.value) {
|
||
clearTimeout(videoPollIntervalId.value)
|
||
videoPollIntervalId.value = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.storyboard-video-create-page {
|
||
height: 100vh;
|
||
background: #0a0a0a;
|
||
color: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin: 0;
|
||
padding: 0;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
|
||
/* 用户菜单样式 - 深色主题 */
|
||
.user-menu-teleport {
|
||
background: #1a1a1a;
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
min-width: 160px;
|
||
overflow: hidden;
|
||
z-index: 99999;
|
||
}
|
||
|
||
.menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease;
|
||
color: white;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.menu-item:hover {
|
||
background: #2a2a2a;
|
||
}
|
||
|
||
.menu-item .el-icon {
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.menu-item:not(:last-child) {
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
|
||
/* 顶部导航栏 */
|
||
.top-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 20px 32px;
|
||
background: #0a0a0a;
|
||
border-bottom: 1px solid #1f1f1f;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.back-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #fff;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
transition: all 0.2s ease;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.back-btn:hover {
|
||
background: #1a1a1a;
|
||
transform: translateX(-2px);
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 24px;
|
||
}
|
||
|
||
.points-display {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: rgba(64, 158, 255, 0.1);
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(64, 158, 255, 0.3);
|
||
}
|
||
|
||
.points-icon {
|
||
width: 20px;
|
||
height: 20px;
|
||
background: #409EFF;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.points-number {
|
||
color: #409EFF;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: transform 0.2s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.user-avatar img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.user-avatar:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* 主内容区域 */
|
||
.main-content {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: 520px 1fr;
|
||
gap: 0;
|
||
height: calc(100vh - 100px);
|
||
}
|
||
|
||
/* 左侧面板 */
|
||
.left-panel {
|
||
background: #1a1a1a;
|
||
border-right: 1px solid #2a2a2a;
|
||
padding: 32px;
|
||
padding-bottom: 120px; /* 为悬浮按钮留出更多空间 */
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden; /* 禁用整体滚动 */
|
||
height: 100%; /* 确保高度限制 */
|
||
position: relative; /* 为悬浮按钮提供定位参考 */
|
||
}
|
||
|
||
/* 左侧面板内容区域(可滚动) */
|
||
.left-panel-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-height: 0; /* 允许 flex 子项缩小 */
|
||
padding-right: 8px; /* 为滚动条留出空间 */
|
||
padding-bottom: 80px; /* 为悬浮按钮留出空间 */
|
||
}
|
||
|
||
/* 优化滚动条样式 */
|
||
.left-panel-content::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.left-panel-content::-webkit-scrollbar-track {
|
||
background: #0a0a0a;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.left-panel-content::-webkit-scrollbar-thumb {
|
||
background: #3a3a3a;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.left-panel-content::-webkit-scrollbar-thumb:hover {
|
||
background: #4a4a4a;
|
||
}
|
||
|
||
/* 创作模式标签 */
|
||
.creation-tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 0;
|
||
}
|
||
|
||
.tab {
|
||
width: 155px;
|
||
height: 44px;
|
||
padding: 0;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
color: #9ca3af;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.tab.active {
|
||
background: #313338;
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.tab:hover:not(.active) {
|
||
background: rgba(49, 51, 56, 0.5);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
/* 分镜步骤标签 - SVG版本 */
|
||
.storyboard-steps-svg {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
padding: 0;
|
||
margin: 8px 0;
|
||
margin-left: 30px;
|
||
}
|
||
|
||
.storyboard-steps-svg svg {
|
||
max-width: 244px;
|
||
height: auto;
|
||
}
|
||
|
||
.storyboard-steps-svg svg path {
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.storyboard-steps-svg svg rect[fill="transparent"]:hover ~ path {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 生成的图片预览 */
|
||
.generated-image-preview {
|
||
width: 100%;
|
||
aspect-ratio: 16/9;
|
||
background: #0a0a0a;
|
||
border: 2px solid #2a2a2a;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.generated-image-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.generated-image-preview .placeholder-text {
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 分镜图区域 */
|
||
.storyboard-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.image-upload-btn {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: #0a0a0a;
|
||
border: 2px dashed #2a2a2a;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
text-align: center;
|
||
color: #9ca3af;
|
||
font-size: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.image-upload-btn:hover {
|
||
border-color: #3b82f6;
|
||
background: #1a1a1a;
|
||
color: #fff;
|
||
}
|
||
|
||
.image-upload-btn.disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
border-color: #1a1a1a;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.image-upload-btn.disabled:hover {
|
||
border-color: #1a1a1a;
|
||
background: #0a0a0a;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.image-count {
|
||
font-size: 12px;
|
||
color: #3b82f6;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 图片输入区域 - 与图生视频一致的样式 */
|
||
.image-input-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.image-upload-area {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
width: 100%;
|
||
}
|
||
|
||
.upload-box {
|
||
flex: none;
|
||
width: 240px;
|
||
max-width: 100%;
|
||
aspect-ratio: 1;
|
||
background: #0a0a0a;
|
||
border: 2px dashed #2a2a2a;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.upload-box:hover {
|
||
border-color: #3b82f6;
|
||
background: #1a1a1a;
|
||
}
|
||
|
||
/* 紧凑型上传框样式 */
|
||
.upload-box-compact {
|
||
width: 136px;
|
||
height: 136px;
|
||
background: #121316;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
position: relative;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.upload-box-compact:hover {
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
background: #1a1d21;
|
||
}
|
||
|
||
.upload-box-compact.drag-over {
|
||
border-color: #3b82f6;
|
||
background: #1a2a3a;
|
||
border-style: solid;
|
||
}
|
||
|
||
.upload-placeholder-compact {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.upload-plus-icon {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.upload-text-compact {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.64);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.upload-preview-compact {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
.upload-preview-compact img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.remove-btn-compact {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
border: none;
|
||
border-radius: 50%;
|
||
color: white;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.remove-btn-compact:hover {
|
||
background: rgba(239, 68, 68, 0.9);
|
||
}
|
||
|
||
/* 视频生成区域 - 参考图布局 */
|
||
.video-reference-section {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.reference-image-large {
|
||
width: 180px;
|
||
height: 180px;
|
||
background: #121316;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 8px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.reference-image-large:hover {
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
background: #1a1d21;
|
||
}
|
||
|
||
.reference-image-large.drag-over {
|
||
border-color: #3b82f6;
|
||
background: #1a2a3a;
|
||
border-style: solid;
|
||
}
|
||
|
||
.reference-preview {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
.reference-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.reference-remove-btn {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
border: none;
|
||
border-radius: 50%;
|
||
color: white;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.2s;
|
||
z-index: 10;
|
||
}
|
||
|
||
.reference-remove-btn:hover {
|
||
background: rgba(239, 68, 68, 0.9);
|
||
}
|
||
|
||
.reference-placeholder {
|
||
width: 100%;
|
||
height: calc(100% - 28px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.reference-placeholder .placeholder-text {
|
||
color: rgba(255, 255, 255, 0.4);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.reference-label {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 28px;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
/* 底部图片行 - 3个固定上传框 */
|
||
.video-images-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
flex-wrap: nowrap;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* 视频参考图上传框 - 紧凑型 */
|
||
.video-upload-box-compact {
|
||
width: 136px;
|
||
height: 136px;
|
||
background: #121316;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
position: relative;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.video-upload-box-compact:hover {
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
background: #1a1d21;
|
||
}
|
||
|
||
.video-upload-box-compact.drag-over {
|
||
border-color: #3b82f6;
|
||
background: #1a2a3a;
|
||
border-style: solid;
|
||
}
|
||
|
||
.video-upload-box-compact.uploaded {
|
||
cursor: default;
|
||
}
|
||
|
||
.video-placeholder-compact {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.video-upload-text {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.64);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.video-preview-compact {
|
||
width: 100%;
|
||
height: calc(100% - 24px);
|
||
position: relative;
|
||
}
|
||
|
||
.video-preview-compact img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 7px 7px 0 0;
|
||
}
|
||
|
||
.video-remove-btn {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
border: none;
|
||
border-radius: 50%;
|
||
color: white;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.video-remove-btn:hover {
|
||
background: rgba(239, 68, 68, 0.9);
|
||
}
|
||
|
||
.video-image-label {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 24px;
|
||
background: #121316;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.64);
|
||
}
|
||
|
||
/* 横向布局容器 */
|
||
.image-upload-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
/* 已上传图片的样式调整 */
|
||
.upload-box-compact.uploaded {
|
||
cursor: default;
|
||
}
|
||
|
||
.upload-box-compact.uploaded .upload-preview-compact {
|
||
height: calc(100% - 24px);
|
||
}
|
||
|
||
/* 图片标签 */
|
||
.image-label {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 24px;
|
||
background: #121316;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.64);
|
||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.generated-preview-box {
|
||
cursor: default;
|
||
border-color: #3b82f6;
|
||
}
|
||
|
||
.generated-preview-box:hover {
|
||
border-color: #3b82f6;
|
||
background: #0a0a0a;
|
||
}
|
||
|
||
.upload-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.upload-icon {
|
||
font-size: 36px;
|
||
color: #6b7280;
|
||
font-weight: 300;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.upload-text {
|
||
font-size: 14px;
|
||
color: #9ca3af;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.upload-preview {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
.upload-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.upload-preview .remove-btn {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.upload-preview .remove-btn:hover {
|
||
background: #ef4444;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.upload-hint-text {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 多图片预览网格 - 保留备用 */
|
||
.images-preview-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
width: 100%;
|
||
padding: 0;
|
||
}
|
||
|
||
/* 单张图片预览 - 保留备用 */
|
||
.single-image-preview {
|
||
position: relative;
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.single-image-preview .preview-image {
|
||
max-width: 100%;
|
||
max-height: 300px;
|
||
border-radius: 8px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.single-image-preview .remove-btn {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.single-image-preview .remove-btn:hover {
|
||
background: #ef4444;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.single-image-preview .reupload-hint {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: 6px 16px;
|
||
background: rgba(59, 130, 246, 0.9);
|
||
color: white;
|
||
border-radius: 16px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.single-image-preview .reupload-hint:hover {
|
||
background: #3b82f6;
|
||
}
|
||
|
||
/* 独立的继续添加按钮(显示在网格下方) */
|
||
.add-more-btn {
|
||
width: 100%;
|
||
height: 60px;
|
||
margin-top: 12px;
|
||
border: 2px dashed #3a3a3a;
|
||
border-radius: 8px;
|
||
background: rgba(59, 130, 246, 0.05);
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.add-more-btn:hover {
|
||
border-color: #3b82f6;
|
||
background: rgba(59, 130, 246, 0.1);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.add-icon {
|
||
font-size: 24px;
|
||
color: #3b82f6;
|
||
line-height: 1;
|
||
}
|
||
|
||
.add-text {
|
||
font-size: 14px;
|
||
color: #9ca3af;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.image-preview-item {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16/9;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
border: 2px solid #2a2a2a;
|
||
background: #1a1a1a;
|
||
transition: all 0.2s ease;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.image-preview-item:hover {
|
||
border-color: #3b82f6;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||
}
|
||
|
||
.image-preview-item img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.image-preview-item:hover img {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.image-preview-item .remove-btn {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: rgba(239, 68, 68, 0.9);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.image-preview-item .remove-btn:hover {
|
||
background: rgba(239, 68, 68, 1);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.image-preview-item .image-index {
|
||
position: absolute;
|
||
bottom: 4px;
|
||
left: 4px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: #fff;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 单张图片预览(保留兼容性) */
|
||
.image-preview {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16/9;
|
||
}
|
||
|
||
.image-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.remove-btn {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: #fff;
|
||
border: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* 文本输入区域 */
|
||
.text-input-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.text-input {
|
||
width: 100%;
|
||
min-height: 90px;
|
||
padding: 12px;
|
||
background: #0a0a0a;
|
||
border: 2px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
resize: vertical;
|
||
outline: none;
|
||
transition: all 0.2s ease;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.text-input:focus {
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.text-input::placeholder {
|
||
color: #6b7280;
|
||
}
|
||
|
||
.input-tips {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
background: rgba(59, 130, 246, 0.05);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||
}
|
||
|
||
.tip-item {
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.tip-item:first-child {
|
||
color: #60a5fa;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tip-item.points {
|
||
color: #fbbf24;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.optimize-btn {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.optimize-button {
|
||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.optimize-button:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||
}
|
||
|
||
/* 视频设置 */
|
||
.video-settings {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.setting-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.setting-item label {
|
||
font-size: 14px;
|
||
color: #e5e7eb;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.setting-select {
|
||
padding: 10px 14px;
|
||
background: #0a0a0a;
|
||
border: 2px solid #2a2a2a;
|
||
border-radius: 8px;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.setting-select:focus {
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.setting-select:hover {
|
||
border-color: #374151;
|
||
}
|
||
|
||
.hd-setting {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.cost-text {
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 生成按钮 */
|
||
.generate-section {
|
||
flex-shrink: 0; /* 防止按钮区域被压缩 */
|
||
padding-top: 16px; /* 与上方内容保持间距 */
|
||
border-top: 1px solid #2a2a2a; /* 可选:添加分隔线 */
|
||
}
|
||
|
||
/* 悬浮的生成按钮 */
|
||
.generate-section.floating {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
left: 80px; /* 居中: (520px - 360px) / 2 */
|
||
width: 360px;
|
||
z-index: 1000;
|
||
background: #1a1a1a;
|
||
padding: 14px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.8), 0 8px 32px rgba(59, 130, 246, 0.3);
|
||
border: 1px solid #3a3a3a;
|
||
backdrop-filter: blur(10px);
|
||
animation: floatIn 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes floatIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.generate-btn {
|
||
width: 100%;
|
||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 16px 24px;
|
||
border-radius: 12px;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.generate-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
|
||
}
|
||
|
||
.generate-btn:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.generate-btn:disabled {
|
||
background: #6b7280;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.generate-btn:disabled:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn-points {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-left: 8px;
|
||
padding: 2px 8px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn-points .el-icon {
|
||
color: #60a5fa;
|
||
}
|
||
|
||
.login-tip-floating {
|
||
margin-top: 12px;
|
||
text-align: center;
|
||
padding: 12px;
|
||
background: rgba(59, 130, 246, 0.1);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.login-tip-floating p {
|
||
color: #9ca3af;
|
||
font-size: 13px;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.login-link-btn {
|
||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.login-link-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||
}
|
||
|
||
/* 右侧面板 */
|
||
.right-panel {
|
||
background: #0a0a0a;
|
||
padding: 32px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* 自定义右侧面板滚动条样式 */
|
||
.right-panel::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.right-panel::-webkit-scrollbar-track {
|
||
background: #1a1a1a;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.right-panel::-webkit-scrollbar-thumb {
|
||
background: #6b7280;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.right-panel::-webkit-scrollbar-thumb:hover {
|
||
background: #9ca3af;
|
||
}
|
||
|
||
.preview-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
min-height: min-content;
|
||
}
|
||
|
||
|
||
.preview-content {
|
||
background: #1a1a1a;
|
||
border: 2px solid #2a2a2a;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
overflow: hidden;
|
||
position: relative;
|
||
width: 80%;
|
||
max-width: 1000px;
|
||
min-height: 300px;
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.preview-content:hover {
|
||
border-color: #374151;
|
||
}
|
||
|
||
.preview-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.placeholder-text {
|
||
font-size: 20px;
|
||
color: #9ca3af;
|
||
font-weight: 500;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 预览图片 */
|
||
.preview-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
overflow: hidden; /* 防止图片溢出容器 */
|
||
box-sizing: border-box;
|
||
position: relative; /* 为进度覆盖层提供定位参考 */
|
||
}
|
||
|
||
.preview-image img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain; /* 保持宽高比,完整显示图片 */
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
/* 加载状态 */
|
||
.preview-loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 20px;
|
||
padding: 40px;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 4px solid #2a2a2a;
|
||
border-top-color: #3b82f6;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 16px;
|
||
color: #9ca3af;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.progress-text {
|
||
font-size: 14px;
|
||
color: #3b82f6;
|
||
font-weight: 600;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
/* 视频播放器 */
|
||
.preview-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
overflow: hidden; /* 防止视频溢出容器 */
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.preview-video video {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain; /* 保持宽高比,完整显示视频 */
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||
background: #000; /* 视频加载时的背景色 */
|
||
}
|
||
|
||
/* 视频进度覆盖层 */
|
||
.video-progress-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 20px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.preview-image {
|
||
position: relative;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 1200px) {
|
||
.main-content {
|
||
grid-template-columns: 420px 1fr;
|
||
}
|
||
|
||
.left-panel {
|
||
padding: 24px;
|
||
padding-bottom: 120px;
|
||
}
|
||
|
||
.generate-section.floating {
|
||
left: 30px; /* 居中: (420px - 360px) / 2 */
|
||
width: 360px;
|
||
}
|
||
|
||
.right-panel {
|
||
padding: 24px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.main-content {
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: auto 1fr;
|
||
}
|
||
|
||
.left-panel {
|
||
border-right: none;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
padding: 20px;
|
||
padding-bottom: 100px;
|
||
}
|
||
|
||
.generate-section.floating {
|
||
left: 20px;
|
||
right: 20px;
|
||
width: auto;
|
||
}
|
||
|
||
.right-panel {
|
||
padding: 20px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.top-header {
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.header-right {
|
||
gap: 16px;
|
||
}
|
||
|
||
.left-panel {
|
||
padding: 16px;
|
||
padding-bottom: 120px;
|
||
}
|
||
|
||
/* 移动端六宫格适配:改为2列布局 */
|
||
.images-preview-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
.generate-section.floating {
|
||
left: 16px;
|
||
right: 16px;
|
||
bottom: 16px;
|
||
width: auto;
|
||
}
|
||
|
||
.left-panel {
|
||
gap: 24px;
|
||
}
|
||
|
||
.right-panel {
|
||
padding: 16px;
|
||
}
|
||
|
||
.creation-tabs {
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tab {
|
||
text-align: left;
|
||
}
|
||
|
||
.storyboard-steps-svg {
|
||
padding: 4px 0;
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.storyboard-steps-svg svg {
|
||
max-width: 220px;
|
||
}
|
||
|
||
.step {
|
||
text-align: left;
|
||
}
|
||
}
|
||
|
||
/* 任务状态样式 */
|
||
.task-status {
|
||
background: #1a1a1a;
|
||
border: 2px solid #2a2a2a;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
width: 80%;
|
||
max-width: 1000px;
|
||
min-height: 300px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.status-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.status-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
}
|
||
|
||
.task-id {
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
font-family: monospace;
|
||
}
|
||
|
||
/* 任务描述样式 - 和历史记录提示词一致 */
|
||
.task-description {
|
||
color: #e5e7eb;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
margin-bottom: 16px;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
/* 视频预览容器 */
|
||
.video-preview-container {
|
||
background: #1a1a1a;
|
||
border: 2px solid #2a2a2a;
|
||
border-radius: 12px;
|
||
min-height: 150px;
|
||
max-height: 350px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 15px 0;
|
||
overflow: hidden;
|
||
padding: 15px;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
max-width: 500px;
|
||
margin-left: 0;
|
||
margin-right: auto;
|
||
}
|
||
|
||
/* 生成中状态 */
|
||
.generating-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 视频生成中状态(显示分镜图 + 进度) */
|
||
.video-generating-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
.video-generating-container .image-display-container {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.video-progress-overlay {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
padding: 20px;
|
||
text-align: center;
|
||
border-radius: 0 0 8px 8px;
|
||
}
|
||
|
||
.video-progress-overlay .generating-text {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.video-progress-overlay .progress-bar-large {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.generating-placeholder {
|
||
text-align: center;
|
||
padding: 40px;
|
||
}
|
||
|
||
.generating-text {
|
||
font-size: 18px;
|
||
color: #3b82f6;
|
||
font-weight: 600;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.progress-bar-large {
|
||
width: 200px;
|
||
height: 8px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.progress-fill-large {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
position: relative;
|
||
}
|
||
|
||
/* 生成中进度条动态动画 - 使用蓝绿渐变风格 */
|
||
.progress-fill-large.animated {
|
||
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
|
||
background-size: 200% 100%;
|
||
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
|
||
}
|
||
|
||
/* 不确定进度条(排队中) */
|
||
.progress-bar-large.indeterminate {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill-indeterminate {
|
||
width: 30%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
|
||
border-radius: 4px;
|
||
animation: progress-move 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
/* 进度百分比文字 */
|
||
.progress-percentage {
|
||
font-size: 14px;
|
||
color: #60a5fa;
|
||
font-weight: 600;
|
||
margin-top: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 完成状态 */
|
||
.completed-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
max-height: 70vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 20px;
|
||
box-sizing: border-box;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* 任务信息头部 */
|
||
.task-info-header {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.task-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.task-checkbox input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
accent-color: #3b82f6;
|
||
}
|
||
|
||
.task-checkbox label {
|
||
font-size: 14px;
|
||
color: #e5e7eb;
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 视频播放容器 */
|
||
.video-player-container {
|
||
flex: 1;
|
||
position: relative;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 0;
|
||
max-height: calc(70vh - 100px);
|
||
width: 100%;
|
||
}
|
||
|
||
.video-player {
|
||
position: relative;
|
||
width: 80%;
|
||
max-width: 1000px;
|
||
aspect-ratio: 16/9;
|
||
background: #000;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.result-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
|
||
/* 图片显示区域 */
|
||
.image-display-container {
|
||
flex: 1;
|
||
position: relative;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 0;
|
||
max-height: calc(70vh - 100px);
|
||
width: 100%;
|
||
}
|
||
|
||
.result-image {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
/* 失败状态 */
|
||
.failed-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px;
|
||
}
|
||
|
||
.failed-placeholder {
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.failed-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.failed-text {
|
||
font-size: 20px;
|
||
color: #ef4444;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.failed-desc {
|
||
font-size: 14px;
|
||
color: #9ca3af;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.failed-reason {
|
||
margin-top: 16px;
|
||
padding: 12px 16px;
|
||
background: rgba(239, 68, 68, 0.1);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||
text-align: left;
|
||
max-width: 400px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
}
|
||
|
||
.failed-reason .reason-label {
|
||
color: #ef4444;
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.failed-reason .reason-text {
|
||
color: #f87171;
|
||
font-size: 13px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.retry-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
padding: 12px 32px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||
border: none;
|
||
border-radius: 24px;
|
||
cursor: pointer;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3), 0 2px 4px -1px rgba(245, 158, 11, 0.1);
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.retry-btn:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #d97706, #b45309);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 10px 15px -3px rgba(245, 158, 11, 0.4), 0 4px 6px -2px rgba(245, 158, 11, 0.2);
|
||
}
|
||
|
||
.retry-btn:active:not(:disabled) {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.retry-btn:disabled {
|
||
background: #6b7280;
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* 分镜图就绪状态 */
|
||
.storyboard-ready-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.storyboard-ready-container .image-display-container {
|
||
max-width: 100%;
|
||
max-height: 80%;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.storyboard-ready-container .result-image {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.storyboard-ready-hint {
|
||
font-size: 14px;
|
||
color: #3b82f6;
|
||
text-align: center;
|
||
padding: 8px 16px;
|
||
background: rgba(59, 130, 246, 0.1);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.storyboard-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.download-storyboard-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #e5e7eb;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.download-storyboard-btn:hover {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-color: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.download-storyboard-btn svg {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.regenerate-btn {
|
||
padding: 8px 20px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.regenerate-btn:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #d97706, #b45309);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.regenerate-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* 其他状态 */
|
||
.status-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.status-text {
|
||
font-size: 18px;
|
||
color: #9ca3af;
|
||
font-weight: 500;
|
||
}
|
||
|
||
|
||
/* 历史记录区域 */
|
||
.history-section {
|
||
margin-top: 24px;
|
||
padding-top: 24px;
|
||
border-top: 1px solid #2a2a2a;
|
||
}
|
||
|
||
.history-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.history-item {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0;
|
||
transition: all 0.2s ease;
|
||
width: 100%;
|
||
max-width: none;
|
||
}
|
||
|
||
.history-status-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.history-status-checkbox input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: default;
|
||
accent-color: #3b82f6;
|
||
}
|
||
|
||
.history-status-checkbox label {
|
||
color: #e5e7eb;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: default;
|
||
}
|
||
|
||
.history-item-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.history-type {
|
||
color: #3b82f6;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.history-date {
|
||
color: #9ca3af;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.history-prompt {
|
||
color: #e5e7eb;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
margin-bottom: 16px;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
width: 80%;
|
||
max-width: 1000px;
|
||
}
|
||
|
||
.history-preview {
|
||
width: 80%;
|
||
max-width: 1000px;
|
||
aspect-ratio: 16/9;
|
||
background: #000;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
margin-bottom: 16px;
|
||
position: relative;
|
||
}
|
||
|
||
|
||
.history-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #000;
|
||
color: #fff;
|
||
gap: 12px;
|
||
}
|
||
|
||
.queue-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
}
|
||
|
||
.queue-link {
|
||
font-size: 14px;
|
||
color: #3b82f6;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.queue-link:hover {
|
||
color: #60a5fa;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.cancel-btn {
|
||
padding: 8px 24px;
|
||
background: transparent;
|
||
color: #fff;
|
||
border: 1px solid #fff;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
margin-top: 8px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.cancel-btn:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.no-result-text {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.history-video-thumbnail {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.history-video-thumbnail video {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
cursor: pointer;
|
||
display: block;
|
||
}
|
||
|
||
.history-image-thumbnail {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.history-image-thumbnail img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.play-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.history-video-thumbnail:hover .play-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.play-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
background: rgba(59, 130, 246, 0.9);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
font-size: 20px;
|
||
padding-left: 4px;
|
||
}
|
||
|
||
.history-actions {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 0;
|
||
}
|
||
|
||
.similar-btn {
|
||
padding: 10px 20px;
|
||
background: #2a2a2a;
|
||
color: #e5e7eb;
|
||
border: 1px solid #3a3a3a;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.similar-btn:hover {
|
||
background: #3a3a3a;
|
||
border-color: #4a4a4a;
|
||
}
|
||
|
||
.generate-video-btn {
|
||
padding: 10px 20px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.generate-video-btn:hover {
|
||
background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.download-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
background: #fff;
|
||
color: #333;
|
||
border: none;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.download-btn:hover {
|
||
background: #f0f0f0;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||
}
|
||
</style>
|
||
|
||
<!-- 非 scoped 样式用于 @keyframes 动画 -->
|
||
<style>
|
||
@keyframes progress-move {
|
||
0% { transform: translateX(-100%); }
|
||
50% { transform: translateX(233%); }
|
||
100% { transform: translateX(-100%); }
|
||
}
|
||
|
||
@keyframes progress-gradient {
|
||
0% { background-position: 0% 50%; }
|
||
100% { background-position: 200% 50%; }
|
||
}
|
||
</style>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
C:\Users\UI\Desktop\AIGC\demo>java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
|
||
Error: Unable to access jarfile target/demo-0.0.1-SNAPSHOT.jar
|
||
|
||
C:\Users\UI\Desktop\AIGC\demo> |