|
|
@@ -0,0 +1,2533 @@
|
|
|
+<template>
|
|
|
+ <div class="video-generation-page">
|
|
|
+ <div class="two-column-layout">
|
|
|
+ <div class="left-column">
|
|
|
+ <el-card class="config-card" shadow="hover">
|
|
|
+ <div class="config-body">
|
|
|
+ <el-form ref="formRef" :model="form" label-position="top" size="small" class="config-form" hide-required-asterisk>
|
|
|
+ <el-form-item class="upload-row">
|
|
|
+ <div class="upload-row-inner">
|
|
|
+ <div
|
|
|
+ v-for="frame in frameUploadList"
|
|
|
+ :key="frame.key"
|
|
|
+ class="upload-cell"
|
|
|
+ >
|
|
|
+ <span class="upload-label">{{ frame.label }}<span class="optional">(可选)</span></span>
|
|
|
+ <div class="upload-cell-inner">
|
|
|
+ <el-upload
|
|
|
+ class="image-uploader square"
|
|
|
+ drag
|
|
|
+ :show-file-list="false"
|
|
|
+ :auto-upload="false"
|
|
|
+ :on-change="(uploadFile) => onFrameFileChange(frame.key, uploadFile)"
|
|
|
+ accept="image/jpeg,image/png,image/webp,image/jpg"
|
|
|
+ >
|
|
|
+ <div v-if="hasFrameImage(frame.key)" class="uploaded-preview">
|
|
|
+ <el-image :src="getFrameDisplayUrl(frame.key)" fit="contain" class="preview-image">
|
|
|
+ <template #error>
|
|
|
+ <div class="image-error"><el-icon><Picture /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <div class="upload-mask">
|
|
|
+ <svg class="swap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <path d="M4 8h12l-3-3 3-3" />
|
|
|
+ <path d="M20 16H8l3 3-3 3" />
|
|
|
+ </svg>
|
|
|
+ <span>更换</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="upload-placeholder">
|
|
|
+ <el-icon :size="20"><Plus /></el-icon>
|
|
|
+ <span>上传{{ frame.label }}</span>
|
|
|
+ </div>
|
|
|
+ </el-upload>
|
|
|
+ <button
|
|
|
+ v-if="hasFrameImage(frame.key)"
|
|
|
+ type="button"
|
|
|
+ class="upload-remove-btn"
|
|
|
+ title="移除"
|
|
|
+ @click.stop="clearFrameImage(frame.key)"
|
|
|
+ >
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="prompt-form-item">
|
|
|
+ <template #label><span>提示词</span></template>
|
|
|
+ <div class="prompt-block">
|
|
|
+ <div v-loading="isOptimizing" element-loading-text="优化中..." class="prompt-wrapper">
|
|
|
+ <el-input
|
|
|
+ v-model="form.prompt"
|
|
|
+ type="textarea"
|
|
|
+ :rows="10"
|
|
|
+ placeholder="请输入提示词..."
|
|
|
+ maxlength="1000"
|
|
|
+ show-word-limit
|
|
|
+ resize="none"
|
|
|
+ class="prompt-textarea"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="prompt-actions-row">
|
|
|
+ <el-button type="success" link size="small" :loading="isOptimizing" @click="optimizePrompt">
|
|
|
+ <el-icon><MagicStick /></el-icon>{{ isOptimizing ? '优化中...' : '优化提示词' }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="config-row-compact">
|
|
|
+ <div class="config-item model-item">
|
|
|
+ <span class="config-label">模型</span>
|
|
|
+ <el-select
|
|
|
+ v-model="form.model"
|
|
|
+ placeholder="请选择模型"
|
|
|
+ size="small"
|
|
|
+ class="model-select"
|
|
|
+ filterable
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="(item, idx) in modelList"
|
|
|
+ :key="item?.id ?? item?.ID ?? idx"
|
|
|
+ :label="item.model_alias || item.model_name || '-'"
|
|
|
+ :value="item.model_name || item.model_alias || ''"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="config-row-compact">
|
|
|
+ <div class="config-item size-item">
|
|
|
+ <span class="config-label">时长</span>
|
|
|
+ <div class="size-group-options">
|
|
|
+ <span
|
|
|
+ v-for="opt in durationOptions"
|
|
|
+ :key="opt.value"
|
|
|
+ class="size-chip"
|
|
|
+ :class="{ active: form.seconds === opt.value }"
|
|
|
+ @click="form.seconds = opt.value"
|
|
|
+ >{{ opt.label }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="config-row-compact">
|
|
|
+ <div class="config-item size-item">
|
|
|
+ <span class="config-label">比例</span>
|
|
|
+ <div class="size-group-options">
|
|
|
+ <span
|
|
|
+ v-for="opt in resolutionOptions"
|
|
|
+ :key="opt.value"
|
|
|
+ class="size-chip"
|
|
|
+ :class="{ active: form.size === opt.value }"
|
|
|
+ @click="form.size = opt.value"
|
|
|
+ >{{ opt.label }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="right-column">
|
|
|
+ <el-card class="result-card" shadow="hover">
|
|
|
+ <template #header>
|
|
|
+ <el-button size="small" class="record-btn-header" @click="openRecordDialog">
|
|
|
+ 视频生成记录
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ <div class="result-area">
|
|
|
+ <div v-if="isGenerating" class="result-loading">
|
|
|
+ <el-icon :size="48" class="loading-icon is-loading"><Loading /></el-icon>
|
|
|
+ <p>视频生成中,请稍等...</p>
|
|
|
+ <el-button type="primary" plain class="result-loading-next-btn" @click="generateVideo">
|
|
|
+ 继续生成下一个视频
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="previewVideoUrl" class="result-preview">
|
|
|
+ <video :key="previewVideoUrl" :src="previewVideoUrl" controls class="result-video" />
|
|
|
+ <div v-if="currentVideoItem" class="result-meta">
|
|
|
+ <p class="result-meta-prompt" :title="currentVideoItem.prompt">{{ currentVideoItem.prompt || '-' }}</p>
|
|
|
+ <span class="result-meta-info">
|
|
|
+ {{ currentVideoItem.model }} · {{ currentVideoItem.seconds }}秒 · {{ currentVideoItem.size }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="result-actions">
|
|
|
+ <el-button type="primary" size="small" @click="generateVideo">再次生成</el-button>
|
|
|
+ <el-button type="primary" size="small" :disabled="!previewVideoUrl" @click="downloadCurrentVideo">
|
|
|
+ 下载
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="result-empty">
|
|
|
+ <el-icon :size="60" class="empty-icon"><VideoCamera /></el-icon>
|
|
|
+ <p>视频将在此处显示</p>
|
|
|
+ <span>填写提示词或上传首帧/尾帧后点击下方按钮生成</span>
|
|
|
+ <el-button type="primary" :loading="isGenerating" size="large" class="generate-btn-center" @click="generateVideo">
|
|
|
+ {{ isGenerating ? '生成中...' : '立即生成' }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="recordDialogVisible"
|
|
|
+ title="视频生成记录"
|
|
|
+ align-center
|
|
|
+ style="width: 90%;height: 90%;"
|
|
|
+ class="video-record-dialog"
|
|
|
+ destroy-on-close
|
|
|
+ @open="onRecordDialogOpen"
|
|
|
+ >
|
|
|
+ <div class="video-record-dialog-inner">
|
|
|
+ <div class="video-record-table-wrap">
|
|
|
+ <el-table :data="historyList" stripe size="small" style="width: 100%;height: 100%;">
|
|
|
+ <el-table-column align="center" prop="id" label="ID" width="70" />
|
|
|
+ <el-table-column label="首帧图" width="72" align="center">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-image
|
|
|
+ v-if="hasHistoryFrameUrl(scope.row, 'first')"
|
|
|
+ :src="historyFrameThumbFromField(scope.row, 'first')"
|
|
|
+ fit="contain"
|
|
|
+ class="history-ref-thumb history-thumb-preview"
|
|
|
+ :preview-src-list="[historyFramePreviewFromField(scope.row, 'first')]"
|
|
|
+ preview-teleported
|
|
|
+ hide-on-click-modal
|
|
|
+ :z-index="IMAGE_PREVIEW_Z_INDEX"
|
|
|
+ >
|
|
|
+ <template #error>
|
|
|
+ <div class="history-thumb-fallback"><el-icon><Picture /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="尾帧图" width="72" align="center">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-image
|
|
|
+ v-if="hasHistoryFrameUrl(scope.row, 'last')"
|
|
|
+ :src="historyFrameThumbFromField(scope.row, 'last')"
|
|
|
+ fit="contain"
|
|
|
+ class="history-ref-thumb history-thumb-preview"
|
|
|
+ :preview-src-list="[historyFramePreviewFromField(scope.row, 'last')]"
|
|
|
+ preview-teleported
|
|
|
+ hide-on-click-modal
|
|
|
+ :z-index="IMAGE_PREVIEW_Z_INDEX"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="视频" width="80" align="center">
|
|
|
+ <template #default="scope">
|
|
|
+ <div
|
|
|
+ v-if="getHistoryVideoPlayUrl(scope.row)"
|
|
|
+ class="history-video-cell"
|
|
|
+ draggable="true"
|
|
|
+ title="点击预览,拖拽可下载视频"
|
|
|
+ @click="viewHistoryTask(scope.row)"
|
|
|
+ @dragstart="onHistoryVideoDragStart($event, scope.row)"
|
|
|
+ >
|
|
|
+ <el-image
|
|
|
+ v-if="historyFrameThumb(scope.row, 'first')"
|
|
|
+ :src="historyFrameThumb(scope.row, 'first')"
|
|
|
+ fit="cover"
|
|
|
+ class="history-ref-thumb"
|
|
|
+ draggable="false"
|
|
|
+ @dragstart.prevent
|
|
|
+ >
|
|
|
+ <template #error>
|
|
|
+ <div class="history-thumb-fallback"><el-icon><VideoCamera /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <div v-else class="history-video-placeholder">
|
|
|
+ <el-icon :size="22"><VideoCamera /></el-icon>
|
|
|
+ </div>
|
|
|
+ <span class="history-video-play-badge">
|
|
|
+ <el-icon :size="16"><CaretRight /></el-icon>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <span v-else class="history-ref-empty">-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="提示词" min-width="200">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-popover
|
|
|
+ v-if="scope.row.prompt"
|
|
|
+ placement="top-start"
|
|
|
+ trigger="hover"
|
|
|
+ :show-after="200"
|
|
|
+ :hide-after="80"
|
|
|
+ :width="300"
|
|
|
+ teleported
|
|
|
+ popper-class="video-record-prompt-popover"
|
|
|
+ >
|
|
|
+ <template #reference>
|
|
|
+ <span class="history-prompt-cell">{{ scope.row.prompt }}</span>
|
|
|
+ </template>
|
|
|
+ <div class="history-prompt-popover-body">{{ scope.row.prompt }}</div>
|
|
|
+ </el-popover>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="model" label="模型" width="220" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="seconds" label="时长" width="64" align="center" />
|
|
|
+ <el-table-column prop="size" label="分辨率" width="100" />
|
|
|
+ <el-table-column prop="sys_rq" label="生成时间" width="168" />
|
|
|
+ <el-table-column label="状态" width="88" align="center">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag size="small" :type="getHistoryVideoStatusType(scope.row)">
|
|
|
+ {{ getHistoryVideoStatusLabel(scope.row) }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="260" fixed="right">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button
|
|
|
+ v-if="scope.row.web_url"
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ link
|
|
|
+ @click="viewHistoryTask(scope.row)"
|
|
|
+ >
|
|
|
+ 查看
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ v-else
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ link
|
|
|
+ @click="viewHistoryTask(scope.row)"
|
|
|
+ >
|
|
|
+ 查看任务
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ v-if="scope.row.web_url"
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ link
|
|
|
+ @click="downloadHistoryVideo(scope.row)"
|
|
|
+ >
|
|
|
+ 下载视频
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ link
|
|
|
+ @click="regenerateFromHistory(scope.row)"
|
|
|
+ >
|
|
|
+ 重新生成
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </div>
|
|
|
+ <div class="gva-pagination record-dialog-pagination">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="page"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :page-sizes="[10, 30, 50, 100]"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ :total="total"
|
|
|
+ @size-change="handleSizeChange"
|
|
|
+ @current-change="handleCurrentChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="recordPreviewVisible"
|
|
|
+ title="视频预览"
|
|
|
+ align-center
|
|
|
+ width="min(96vw, 1180px)"
|
|
|
+ class="video-record-preview-dialog"
|
|
|
+ append-to-body
|
|
|
+ destroy-on-close
|
|
|
+ >
|
|
|
+ <div v-if="recordPreviewItem" class="record-preview-body">
|
|
|
+ <div class="record-preview-meta-grid">
|
|
|
+ <div class="meta-grid-item meta-grid-item-status">
|
|
|
+ <span class="meta-grid-label">状态</span>
|
|
|
+ <el-tag
|
|
|
+ v-if="getHistoryVideoStatus(recordPreviewItem) === 'failed'"
|
|
|
+ size="small"
|
|
|
+ type="danger"
|
|
|
+ >失败</el-tag>
|
|
|
+ <el-tag
|
|
|
+ v-else-if="recordPreviewPlayUrl"
|
|
|
+ size="small"
|
|
|
+ type="success"
|
|
|
+ >已完成</el-tag>
|
|
|
+ <el-tag v-else size="small" type="warning">生成中</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="meta-grid-item meta-grid-item-model">
|
|
|
+ <span class="meta-grid-label">模型</span>
|
|
|
+ <span class="meta-grid-value" :title="recordPreviewItem.model">{{ recordPreviewItem.model || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="meta-grid-item">
|
|
|
+ <span class="meta-grid-label">比例</span>
|
|
|
+ <span class="meta-grid-value">{{ recordPreviewItem.size || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="meta-grid-item">
|
|
|
+ <span class="meta-grid-label">时长</span>
|
|
|
+ <span class="meta-grid-value">{{ getPreviewDuration(recordPreviewItem) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="meta-grid-item">
|
|
|
+ <span class="meta-grid-label">生成时间</span>
|
|
|
+ <span class="meta-grid-value">{{ recordPreviewItem.sys_rq || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="record-preview-task-row">
|
|
|
+ <span class="meta-grid-label">任务ID</span>
|
|
|
+ <span class="preview-task-id-text" :title="getPreviewTaskId(recordPreviewItem)">
|
|
|
+ {{ getPreviewTaskId(recordPreviewItem) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ class="record-preview-stage"
|
|
|
+ :class="{ 'has-frames': hasHistoryFrameUrl(recordPreviewItem, 'first') || hasHistoryFrameUrl(recordPreviewItem, 'last') }"
|
|
|
+ >
|
|
|
+ <div class="record-preview-video-panel media-frame-video">
|
|
|
+ <div v-if="recordPreviewPlayUrl && !taskQueryLoading" class="record-preview-video-wrap">
|
|
|
+ <video
|
|
|
+ :key="recordPreviewPlayUrl"
|
|
|
+ :src="recordPreviewPlayUrl"
|
|
|
+ controls
|
|
|
+ class="record-preview-video"
|
|
|
+ @loadstart="videoPreviewLoading = true"
|
|
|
+ @canplay="videoPreviewLoading = false"
|
|
|
+ @loadeddata="videoPreviewLoading = false"
|
|
|
+ @error="videoPreviewLoading = false"
|
|
|
+ />
|
|
|
+ <div v-if="videoPreviewLoading" class="video-area-loading">
|
|
|
+ <el-icon :size="32" class="is-loading"><Loading /></el-icon>
|
|
|
+ <span>视频生成中,请稍等...</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="!taskQueryLoading && getHistoryVideoStatus(recordPreviewItem) === 'failed'" class="media-fallback media-failed">
|
|
|
+ <el-icon :size="28"><VideoCamera /></el-icon>
|
|
|
+ <span>视频生成失败</span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="media-fallback media-generating video-area-loading">
|
|
|
+ <el-icon :size="32" class="is-loading"><Loading /></el-icon>
|
|
|
+ <span>视频生成中,请稍等...</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <aside
|
|
|
+ v-if="hasHistoryFrameUrl(recordPreviewItem, 'first') || hasHistoryFrameUrl(recordPreviewItem, 'last')"
|
|
|
+ class="record-preview-frames-side"
|
|
|
+ >
|
|
|
+ <div v-if="hasHistoryFrameUrl(recordPreviewItem, 'first')" class="frame-side-card">
|
|
|
+ <span class="frame-side-label">首帧</span>
|
|
|
+ <div class="frame-side-thumb">
|
|
|
+ <el-image
|
|
|
+ :src="historyFramePreviewFromField(recordPreviewItem, 'first')"
|
|
|
+ fit="cover"
|
|
|
+ class="frame-thumb-image"
|
|
|
+ :preview-src-list="[historyFramePreviewFromField(recordPreviewItem, 'first')]"
|
|
|
+ preview-teleported
|
|
|
+ hide-on-click-modal
|
|
|
+ :z-index="IMAGE_PREVIEW_Z_INDEX"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <el-button type="primary" link size="small" @click="downloadPreviewFrame('first')">下载</el-button>
|
|
|
+ </div>
|
|
|
+ <div v-if="hasHistoryFrameUrl(recordPreviewItem, 'last')" class="frame-side-card">
|
|
|
+ <span class="frame-side-label">尾帧</span>
|
|
|
+ <div class="frame-side-thumb">
|
|
|
+ <el-image
|
|
|
+ :src="historyFramePreviewFromField(recordPreviewItem, 'last')"
|
|
|
+ fit="cover"
|
|
|
+ class="frame-thumb-image"
|
|
|
+ :preview-src-list="[historyFramePreviewFromField(recordPreviewItem, 'last')]"
|
|
|
+ preview-teleported
|
|
|
+ hide-on-click-modal
|
|
|
+ :z-index="IMAGE_PREVIEW_Z_INDEX"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <el-button type="primary" link size="small" @click="downloadPreviewFrame('last')">下载</el-button>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="recordPreviewItem.task_query_msg || getHistoryVideoStatus(recordPreviewItem) === 'failed'"
|
|
|
+ class="record-preview-alert"
|
|
|
+ >
|
|
|
+ <p
|
|
|
+ v-if="recordPreviewItem.task_query_msg"
|
|
|
+ class="record-preview-task-msg"
|
|
|
+ :class="{ 'is-fail': getHistoryVideoStatus(recordPreviewItem) === 'failed' }"
|
|
|
+ >
|
|
|
+ {{ recordPreviewItem.task_query_msg }}
|
|
|
+ </p>
|
|
|
+ <p v-else-if="getHistoryVideoStatus(recordPreviewItem) === 'failed'" class="record-preview-fail">
|
|
|
+ {{ getVideoRowFailMessage(recordPreviewItem) }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="record-preview-prompt-block">
|
|
|
+ <div class="record-preview-section-title">提示词</div>
|
|
|
+ <div class="record-preview-prompt-scroll">
|
|
|
+ {{ recordPreviewItem.prompt || '-' }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <div class="record-preview-footer">
|
|
|
+ <el-button type="primary" size="small" :disabled="!recordPreviewPlayUrl" @click="downloadRecordPreview">下载视频</el-button>
|
|
|
+ <el-button size="small" @click="recordPreviewVisible = false">关闭</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, reactive, shallowReactive, onMounted, onBeforeUnmount, toRaw } from 'vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import { Plus, Picture, Close, Loading, VideoCamera, MagicStick, CaretRight } from '@element-plus/icons-vue'
|
|
|
+import { Getvideolist, Create_ImgToVideo, Get_ImgToVideo, GetAIModel, CallAIModelApi } from '@/api/mes/job'
|
|
|
+import { displayImageUrl, displayImageUrlWithReference, materialThumbnailUrl } from '@/utils/displayImageUrl.js'
|
|
|
+import { useUserStore } from '@/pinia/modules/user'
|
|
|
+
|
|
|
+const userStore = useUserStore()
|
|
|
+/** 图片放大预览层级,须高于多层 dialog 遮罩 */
|
|
|
+const IMAGE_PREVIEW_Z_INDEX = 10001
|
|
|
+const modelList = ref([])
|
|
|
+const FRAME_KEYS = ['first', 'last']
|
|
|
+const frameUploadList = [
|
|
|
+ { key: 'first', label: '首帧图', field: 'first_image_url' },
|
|
|
+ { key: 'last', label: '尾帧图', field: 'last_image_url' }
|
|
|
+]
|
|
|
+const framePreviewUrls = reactive({ first: '', last: '' })
|
|
|
+/** 存 File 用 shallowReactive,避免 deep reactive 导致 instanceof File 失效 */
|
|
|
+const frameFiles = shallowReactive({ first: null, last: null })
|
|
|
+const MAX_REF_IMAGE_MB = 10
|
|
|
+const VIDEO_SCENE_TYPES = ['文本生视频', '文本图片生视频', '文本视频生视频']
|
|
|
+
|
|
|
+function parseModelTypes (modelType) {
|
|
|
+ if (modelType == null || modelType === '') return []
|
|
|
+ return String(modelType).split(/[,,]/).map((t) => t.trim()).filter(Boolean)
|
|
|
+}
|
|
|
+
|
|
|
+function modelSupportsScene (item, scenes = VIDEO_SCENE_TYPES) {
|
|
|
+ const types = parseModelTypes(item?.model_type)
|
|
|
+ return scenes.some((scene) => types.includes(scene))
|
|
|
+}
|
|
|
+
|
|
|
+function dedupeModelList (list) {
|
|
|
+ const seenName = new Set()
|
|
|
+ const seenLabel = new Set()
|
|
|
+ const out = []
|
|
|
+ for (const item of list) {
|
|
|
+ const name = (item.model_name || '').trim().toLowerCase()
|
|
|
+ const alias = (item.model_alias || '').trim().toLowerCase()
|
|
|
+ const label = (alias || name).trim().toLowerCase()
|
|
|
+ if (name && seenName.has(name)) continue
|
|
|
+ if (label && seenLabel.has(label)) continue
|
|
|
+ if (name) seenName.add(name)
|
|
|
+ if (label) seenLabel.add(label)
|
|
|
+ out.push(item)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+const defaultModelValue = () => {
|
|
|
+ const first = modelList.value[0]
|
|
|
+ return first ? (first.model_name || first.model_alias || '') : ''
|
|
|
+}
|
|
|
+
|
|
|
+const loadModelList = async () => {
|
|
|
+ try {
|
|
|
+ const res = await GetAIModel({})
|
|
|
+ const list = res?.data?.list ?? res?.data ?? []
|
|
|
+ const raw = (Array.isArray(list) ? list : []).filter((item) => modelSupportsScene(item))
|
|
|
+ modelList.value = dedupeModelList(raw)
|
|
|
+ if (modelList.value.length > 0) {
|
|
|
+ const cur = (form.model || '').trim()
|
|
|
+ const exists = modelList.value.some((m) => (m.model_name || m.model_alias || '') === cur)
|
|
|
+ if (!cur || !exists) form.model = defaultModelValue()
|
|
|
+ } else {
|
|
|
+ form.model = ''
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ modelList.value = []
|
|
|
+ form.model = ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const durationOptions = [
|
|
|
+ { label: '5秒', value: 5 },
|
|
|
+ { label: '6秒', value: 6 },
|
|
|
+ { label: '7秒', value: 7 },
|
|
|
+ { label: '8秒', value: 8 },
|
|
|
+ { label: '9秒', value: 9 },
|
|
|
+ { label: '10秒', value: 10 },
|
|
|
+ { label: '11秒', value: 11 },
|
|
|
+ { label: '12秒', value: 12 }
|
|
|
+]
|
|
|
+
|
|
|
+const resolutionOptions = [
|
|
|
+ { label: '1:1', value: '1:1' },
|
|
|
+ { label: '9:16', value: '9:16' },
|
|
|
+ { label: '16:9', value: '16:9' }
|
|
|
+]
|
|
|
+
|
|
|
+const form = reactive({
|
|
|
+ prompt: '',
|
|
|
+ first_image_url: '',
|
|
|
+ last_image_url: '',
|
|
|
+ model: '',
|
|
|
+ seconds: 5,
|
|
|
+ size: '16:9'
|
|
|
+})
|
|
|
+
|
|
|
+const formRef = ref()
|
|
|
+const isGenerating = ref(false)
|
|
|
+let generateSessionId = 0
|
|
|
+
|
|
|
+const clearGeneratingState = () => {
|
|
|
+ isGenerating.value = false
|
|
|
+}
|
|
|
+const isOptimizing = ref(false)
|
|
|
+const previewVideoUrl = ref('')
|
|
|
+const recordDialogVisible = ref(false)
|
|
|
+const recordPreviewVisible = ref(false)
|
|
|
+const recordPreviewPlayUrl = ref('')
|
|
|
+const recordPreviewItem = ref(null)
|
|
|
+const taskQueryLoading = ref(false)
|
|
|
+const videoPreviewLoading = ref(false)
|
|
|
+const currentVideoItem = ref(null)
|
|
|
+const historyList = ref([])
|
|
|
+const page = ref(1)
|
|
|
+const pageSize = ref(9)
|
|
|
+const total = ref(0)
|
|
|
+const searchInfo = ref('')
|
|
|
+
|
|
|
+const OSS_ASSET_FALLBACK = 'https://a-7in6-com.oss-cn-hangzhou.aliyuncs.com'
|
|
|
+
|
|
|
+const getOssAssetBase = () => {
|
|
|
+ const v = import.meta.env.VITE_OSS_ASSET_BASE
|
|
|
+ return (v && String(v).trim()) ? String(v).trim().replace(/\/$/, '') : OSS_ASSET_FALLBACK.replace(/\/$/, '')
|
|
|
+}
|
|
|
+
|
|
|
+/** OSS / uploads 相对路径、完整 https 链统一为可访问地址(图片、视频共用) */
|
|
|
+const formatAssetUrl = (path, referenceUrl) => {
|
|
|
+ if (path == null || !String(path).trim()) return ''
|
|
|
+ const raw = String(path).trim()
|
|
|
+ let out = referenceUrl ? displayImageUrlWithReference(raw, referenceUrl) : ''
|
|
|
+ if (!out) out = displayImageUrl(raw)
|
|
|
+ if (!out) return ''
|
|
|
+ if (/^(https?:|\/\/|data:|blob:)/i.test(out)) return out
|
|
|
+ return `${getOssAssetBase()}/${out.replace(/^\//, '')}`
|
|
|
+}
|
|
|
+
|
|
|
+const formatRefImageUrl = (path, referenceUrl) => formatAssetUrl(path, referenceUrl)
|
|
|
+const formatVideoUrl = (path, referenceUrl) => formatAssetUrl(path, referenceUrl)
|
|
|
+
|
|
|
+const pickRowAssetPath = (row, ...keys) => {
|
|
|
+ for (const k of keys) {
|
|
|
+ const v = row?.[k]
|
|
|
+ if (v != null && String(v).trim()) return String(v).trim()
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryRowAssetRef = (row) =>
|
|
|
+ pickRowAssetPath(row, 'web_url', 'first_image_url', 'last_image_url', 'image_url')
|
|
|
+
|
|
|
+/** 记录表格缩略图边长:略大于展示尺寸,避免 48px 格子里发糊 */
|
|
|
+const HISTORY_TABLE_THUMB_EDGE = 144
|
|
|
+
|
|
|
+const formatRefThumbUrl = (path, referenceUrl, maxEdge = 64) => {
|
|
|
+ const full = formatAssetUrl(path, referenceUrl)
|
|
|
+ if (!full) return ''
|
|
|
+ return materialThumbnailUrl(full, maxEdge) || full
|
|
|
+}
|
|
|
+
|
|
|
+/** 无首帧图时,用 OSS 视频截帧做列表缩略图(旧任务常只有 web_url) */
|
|
|
+const getOssVideoSnapshotUrl = (videoUrl, maxEdge = 128) => {
|
|
|
+ if (!videoUrl) return ''
|
|
|
+ let u = videoUrl
|
|
|
+ if (u.startsWith('//')) u = `https:${u}`
|
|
|
+ if (!/^https?:\/\//i.test(u)) return ''
|
|
|
+ try {
|
|
|
+ const parsed = new URL(u)
|
|
|
+ if (!parsed.hostname.includes('aliyuncs.com')) return ''
|
|
|
+ if (parsed.searchParams.has('x-oss-process')) return u
|
|
|
+ parsed.searchParams.set(
|
|
|
+ 'x-oss-process',
|
|
|
+ `video/snapshot,t_0,f_jpg,w_${maxEdge},h_${maxEdge},m_fast`
|
|
|
+ )
|
|
|
+ return parsed.toString()
|
|
|
+ } catch {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryFrameCells = (row) => {
|
|
|
+ const ref = getHistoryRowAssetRef(row)
|
|
|
+ const firstRaw = pickRowAssetPath(row, 'first_image_url', 'first_image', 'FirstImageUrl', 'image_url')
|
|
|
+ const lastRaw = pickRowAssetPath(row, 'last_image_url', 'last_image', 'LastImageUrl')
|
|
|
+ const firstPreview = formatAssetUrl(firstRaw, ref)
|
|
|
+ const lastPreview = formatAssetUrl(lastRaw, ref)
|
|
|
+ let first = formatRefThumbUrl(firstRaw, ref)
|
|
|
+ let firstPreviewOut = firstPreview
|
|
|
+ if (!first && row?.web_url) {
|
|
|
+ const videoFull = formatAssetUrl(row.web_url, ref)
|
|
|
+ const snap = getOssVideoSnapshotUrl(videoFull, 64)
|
|
|
+ if (snap) {
|
|
|
+ first = snap
|
|
|
+ firstPreviewOut = getOssVideoSnapshotUrl(videoFull, 720) || snap
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ first,
|
|
|
+ last: formatRefThumbUrl(lastRaw, ref),
|
|
|
+ firstPreview: firstPreviewOut,
|
|
|
+ lastPreview
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryFrameRawUrl = (row, which) => {
|
|
|
+ if (which === 'first') {
|
|
|
+ return pickRowAssetPath(row, 'first_image_url', 'first_image', 'FirstImageUrl')
|
|
|
+ }
|
|
|
+ return pickRowAssetPath(row, 'last_image_url', 'last_image', 'LastImageUrl')
|
|
|
+}
|
|
|
+
|
|
|
+const hasHistoryFrameUrl = (row, which) => !!getHistoryFrameRawUrl(row, which)
|
|
|
+
|
|
|
+const historyFrameThumbFromField = (row, which) => {
|
|
|
+ const raw = getHistoryFrameRawUrl(row, which)
|
|
|
+ if (!raw) return ''
|
|
|
+ return formatRefThumbUrl(raw, getHistoryRowAssetRef(row), HISTORY_TABLE_THUMB_EDGE)
|
|
|
+}
|
|
|
+
|
|
|
+const historyFramePreviewFromField = (row, which) => {
|
|
|
+ const raw = getHistoryFrameRawUrl(row, which)
|
|
|
+ if (!raw) return ''
|
|
|
+ return formatAssetUrl(raw, getHistoryRowAssetRef(row))
|
|
|
+}
|
|
|
+
|
|
|
+const historyFrameThumb = (row, which) => {
|
|
|
+ const cells = getHistoryFrameCells(row)
|
|
|
+ return which === 'first' ? cells.first : cells.last
|
|
|
+}
|
|
|
+
|
|
|
+const historyFramePreview = (row, which) => {
|
|
|
+ const cells = getHistoryFrameCells(row)
|
|
|
+ return which === 'first' ? cells.firstPreview : cells.lastPreview
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryVideoPlayUrl = (row) => {
|
|
|
+ if (!row?.web_url) return ''
|
|
|
+ return formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+}
|
|
|
+
|
|
|
+const getFrameField = (key) => (key === 'first' ? 'first_image_url' : 'last_image_url')
|
|
|
+const getFrameLabel = (key) => (key === 'first' ? '首帧' : '尾帧')
|
|
|
+
|
|
|
+const hasFrameImage = (key) => {
|
|
|
+ return !!(frameFiles[key] || framePreviewUrls[key])
|
|
|
+}
|
|
|
+
|
|
|
+const getFrameDisplayUrl = (key) => {
|
|
|
+ if (framePreviewUrls[key]) return framePreviewUrls[key]
|
|
|
+ const field = getFrameField(key)
|
|
|
+ const ref = form.first_image_url || form.last_image_url
|
|
|
+ return formatRefImageUrl(form[field], ref)
|
|
|
+}
|
|
|
+
|
|
|
+const hasAnyFrameImage = () =>
|
|
|
+ FRAME_KEYS.some((key) => hasFrameImage(key)) ||
|
|
|
+ !!(form.first_image_url?.trim() || form.last_image_url?.trim())
|
|
|
+
|
|
|
+/** auto-upload=false 时不会走 before-upload,须在 on-change 里处理选图 */
|
|
|
+const getFrameRawFile = (key) => {
|
|
|
+ const f = frameFiles[key]
|
|
|
+ if (!f) return null
|
|
|
+ const raw = toRaw(f)
|
|
|
+ return raw instanceof File ? raw : (f instanceof File ? f : null)
|
|
|
+}
|
|
|
+
|
|
|
+const validateFrameImageFile = (file) => {
|
|
|
+ if (!file) return false
|
|
|
+ const mimeOk = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'].includes(file.type)
|
|
|
+ const extOk = /\.(jpe?g|png|webp)$/i.test(file.name || '')
|
|
|
+ if (!mimeOk && !extOk) {
|
|
|
+ ElMessage.error('仅支持 jpg、png、webp 格式')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (file.size / 1024 / 1024 > MAX_REF_IMAGE_MB) {
|
|
|
+ ElMessage.error(`图片大小不能超过 ${MAX_REF_IMAGE_MB}MB`)
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+const applyFrameImageFile = (key, file) => {
|
|
|
+ if (!validateFrameImageFile(file)) return
|
|
|
+ if (framePreviewUrls[key]) URL.revokeObjectURL(framePreviewUrls[key])
|
|
|
+ frameFiles[key] = file
|
|
|
+ framePreviewUrls[key] = URL.createObjectURL(file)
|
|
|
+ form[getFrameField(key)] = ''
|
|
|
+}
|
|
|
+
|
|
|
+const onFrameFileChange = (key, uploadFile) => {
|
|
|
+ const file = uploadFile?.raw
|
|
|
+ if (!file) return
|
|
|
+ applyFrameImageFile(key, file)
|
|
|
+}
|
|
|
+
|
|
|
+const clearFrameImage = (key, showMsg = true) => {
|
|
|
+ if (framePreviewUrls[key]) {
|
|
|
+ URL.revokeObjectURL(framePreviewUrls[key])
|
|
|
+ framePreviewUrls[key] = ''
|
|
|
+ }
|
|
|
+ frameFiles[key] = null
|
|
|
+ form[getFrameField(key)] = ''
|
|
|
+ if (showMsg) ElMessage.info(`已移除${getFrameLabel(key)}`)
|
|
|
+}
|
|
|
+
|
|
|
+const clearAllFrameImages = (showMsg = false) => {
|
|
|
+ FRAME_KEYS.forEach((key) => clearFrameImage(key, false))
|
|
|
+ if (showMsg) ElMessage.info('已移除首尾帧')
|
|
|
+}
|
|
|
+
|
|
|
+const fetchImageAsBase64 = async (imageUrl) => {
|
|
|
+ if (!imageUrl) return ''
|
|
|
+ const url = formatRefImageUrl(imageUrl)
|
|
|
+ if (!url) return ''
|
|
|
+ if (url.startsWith('data:')) return url
|
|
|
+ try {
|
|
|
+ const fetchUrl = /^https?:\/\//i.test(url) ? url : (url.startsWith('/') ? url : `/${url}`)
|
|
|
+ const res = await fetch(fetchUrl, {
|
|
|
+ headers: { 'x-token': userStore.token },
|
|
|
+ credentials: 'include',
|
|
|
+ })
|
|
|
+ if (!res.ok) return ''
|
|
|
+ const blob = await res.blob()
|
|
|
+ const file = new File([blob], 'frame.jpg', { type: blob.type || 'image/jpeg' })
|
|
|
+ return await fileToBase64Compressed(file)
|
|
|
+ } catch {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 压缩后转 data:image/xxx;base64,... */
|
|
|
+const fileToBase64Compressed = (file, maxSize = 1920, quality = 0.82) =>
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
+ const url = URL.createObjectURL(file)
|
|
|
+ const img = new Image()
|
|
|
+ img.onload = () => {
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+ let w = img.naturalWidth
|
|
|
+ let h = img.naturalHeight
|
|
|
+ if (w > maxSize || h > maxSize) {
|
|
|
+ if (w > h) {
|
|
|
+ h = Math.round((h * maxSize) / w)
|
|
|
+ w = maxSize
|
|
|
+ } else {
|
|
|
+ w = Math.round((w * maxSize) / h)
|
|
|
+ h = maxSize
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ canvas.width = w
|
|
|
+ canvas.height = h
|
|
|
+ const ctx = canvas.getContext('2d')
|
|
|
+ ctx.drawImage(img, 0, 0, w, h)
|
|
|
+ resolve(canvas.toDataURL('image/jpeg', quality))
|
|
|
+ }
|
|
|
+ img.onerror = () => {
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+ reject(new Error('图片加载失败'))
|
|
|
+ }
|
|
|
+ img.src = url
|
|
|
+ })
|
|
|
+
|
|
|
+const setPreviewVideo = (row) => {
|
|
|
+ if (!row?.web_url) return
|
|
|
+ currentVideoItem.value = row
|
|
|
+ previewVideoUrl.value = formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+}
|
|
|
+
|
|
|
+const fetchAndShowHistoryTask = async (row) => {
|
|
|
+ const taskId = row?.task_id || row?.video_id
|
|
|
+ if (!taskId) {
|
|
|
+ recordPreviewItem.value = row
|
|
|
+ recordPreviewPlayUrl.value = row?.web_url
|
|
|
+ ? formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+ : ''
|
|
|
+ return
|
|
|
+ }
|
|
|
+ taskQueryLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await Get_ImgToVideo(
|
|
|
+ { task_id: taskId, sys_id: userStore.userInfo.nickName },
|
|
|
+ { skipErrorMessage: true, donNotShowLoading: true }
|
|
|
+ )
|
|
|
+ const merged = mergeTaskFromApiResponse(row, res)
|
|
|
+ patchHistoryRowByTaskId(taskId, merged)
|
|
|
+ recordPreviewItem.value = merged
|
|
|
+ const nextPlayUrl = merged.web_url
|
|
|
+ ? formatVideoUrl(merged.web_url, getHistoryRowAssetRef(merged))
|
|
|
+ : ''
|
|
|
+ recordPreviewPlayUrl.value = nextPlayUrl
|
|
|
+ videoPreviewLoading.value = !!nextPlayUrl
|
|
|
+ } catch (error) {
|
|
|
+ console.error('查询任务失败', error)
|
|
|
+ recordPreviewItem.value = row
|
|
|
+ recordPreviewPlayUrl.value = row?.web_url
|
|
|
+ ? formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+ : ''
|
|
|
+ ElMessage.error('查询任务失败')
|
|
|
+ } finally {
|
|
|
+ taskQueryLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const viewHistoryTask = async (row) => {
|
|
|
+ if (!row) return
|
|
|
+ recordPreviewVisible.value = true
|
|
|
+ recordPreviewItem.value = row
|
|
|
+ const playUrl = row.web_url
|
|
|
+ ? formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+ : ''
|
|
|
+ recordPreviewPlayUrl.value = playUrl
|
|
|
+ videoPreviewLoading.value = !!playUrl
|
|
|
+ const needQuery = !row.web_url || getHistoryVideoStatus(row) !== 'success'
|
|
|
+ if (needQuery) {
|
|
|
+ await fetchAndShowHistoryTask(row)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const openRecordPreview = (row) => {
|
|
|
+ viewHistoryTask(row)
|
|
|
+}
|
|
|
+
|
|
|
+const applyHistoryRowToForm = (row) => {
|
|
|
+ if (!row) return
|
|
|
+ form.prompt = row.prompt || ''
|
|
|
+ if (row.model) form.model = row.model
|
|
|
+ if (row.seconds != null && row.seconds !== '') {
|
|
|
+ form.seconds = Number(row.seconds) || form.seconds
|
|
|
+ }
|
|
|
+ if (row.size) form.size = row.size
|
|
|
+ FRAME_KEYS.forEach((key) => clearFrameImage(key, false))
|
|
|
+ const ref = getHistoryRowAssetRef(row)
|
|
|
+ const firstRaw = getHistoryFrameRawUrl(row, 'first')
|
|
|
+ const lastRaw = getHistoryFrameRawUrl(row, 'last')
|
|
|
+ form.first_image_url = firstRaw ? formatAssetUrl(firstRaw, ref) : ''
|
|
|
+ form.last_image_url = lastRaw ? formatAssetUrl(lastRaw, ref) : ''
|
|
|
+}
|
|
|
+
|
|
|
+const regenerateFromHistory = async (row) => {
|
|
|
+ applyHistoryRowToForm(row)
|
|
|
+ recordDialogVisible.value = false
|
|
|
+ await generateVideo()
|
|
|
+}
|
|
|
+
|
|
|
+const buildVideoFilename = (item) => {
|
|
|
+ const id = item?.task_id || item?.video_id || item?.id || Date.now()
|
|
|
+ return `video_${id}.mp4`
|
|
|
+}
|
|
|
+
|
|
|
+const triggerBlobDownload = (blob, filename) => {
|
|
|
+ const blobUrl = URL.createObjectURL(blob)
|
|
|
+ const link = document.createElement('a')
|
|
|
+ link.href = blobUrl
|
|
|
+ link.download = filename
|
|
|
+ link.style.display = 'none'
|
|
|
+ document.body.appendChild(link)
|
|
|
+ link.click()
|
|
|
+ setTimeout(() => {
|
|
|
+ document.body.removeChild(link)
|
|
|
+ URL.revokeObjectURL(blobUrl)
|
|
|
+ }, 200)
|
|
|
+}
|
|
|
+
|
|
|
+const withOssAttachmentDisposition = (url, name) => {
|
|
|
+ try {
|
|
|
+ const u = new URL(url)
|
|
|
+ if (!u.hostname.includes('aliyuncs.com')) return url
|
|
|
+ if (u.searchParams.has('response-content-disposition')) return url
|
|
|
+ u.searchParams.set('response-content-disposition', `attachment;filename=${encodeURIComponent(name)}`)
|
|
|
+ return u.toString()
|
|
|
+ } catch {
|
|
|
+ return url
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getPreviewTaskId = (row) => row?.video_id || row?.task_id || row?.id || '-'
|
|
|
+
|
|
|
+const getPreviewDuration = (row) => {
|
|
|
+ const seconds = row?.seconds
|
|
|
+ if (seconds == null || seconds === '') return '-'
|
|
|
+ return `${seconds}秒`
|
|
|
+}
|
|
|
+
|
|
|
+const downloadAssetByUrl = async (rawUrl, filename, loadingText = '下载中...') => {
|
|
|
+ if (!rawUrl) return
|
|
|
+ const normalized = String(rawUrl).trim()
|
|
|
+
|
|
|
+ if (normalized.startsWith('blob:')) {
|
|
|
+ const link = document.createElement('a')
|
|
|
+ link.href = normalized
|
|
|
+ link.download = filename
|
|
|
+ link.style.display = 'none'
|
|
|
+ document.body.appendChild(link)
|
|
|
+ link.click()
|
|
|
+ document.body.removeChild(link)
|
|
|
+ ElMessage.success('下载完成')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const fetchUrl = normalized.startsWith('http')
|
|
|
+ ? normalized
|
|
|
+ : (normalized.startsWith('/') ? normalized : `/${normalized}`)
|
|
|
+
|
|
|
+ const tryFetchBlob = async (url) => {
|
|
|
+ const res = await fetch(url, {
|
|
|
+ headers: userStore.token ? { 'x-token': userStore.token } : {},
|
|
|
+ credentials: 'include',
|
|
|
+ })
|
|
|
+ if (!res.ok) throw new Error(res.statusText || String(res.status))
|
|
|
+ return res.blob()
|
|
|
+ }
|
|
|
+
|
|
|
+ ElMessage.info(loadingText)
|
|
|
+ const fetchCandidates = [fetchUrl]
|
|
|
+ if (!fetchUrl.startsWith('http')) {
|
|
|
+ fetchCandidates.push(`${window.location.origin}${fetchUrl}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const url of fetchCandidates) {
|
|
|
+ try {
|
|
|
+ const blob = await tryFetchBlob(url)
|
|
|
+ triggerBlobDownload(blob, filename)
|
|
|
+ ElMessage.success('下载完成')
|
|
|
+ return
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('[下载] fetch 失败:', url, e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const attachUrl = withOssAttachmentDisposition(
|
|
|
+ fetchUrl.startsWith('http') ? fetchUrl : `${window.location.origin}${fetchUrl}`,
|
|
|
+ filename
|
|
|
+ )
|
|
|
+ const link = document.createElement('a')
|
|
|
+ link.href = attachUrl
|
|
|
+ link.download = filename
|
|
|
+ link.rel = 'noopener'
|
|
|
+ link.target = '_blank'
|
|
|
+ link.style.display = 'none'
|
|
|
+ document.body.appendChild(link)
|
|
|
+ link.click()
|
|
|
+ document.body.removeChild(link)
|
|
|
+ ElMessage.warning('已尝试触发下载,若未保存请在新页面右键另存为')
|
|
|
+}
|
|
|
+
|
|
|
+const downloadVideoByUrl = (rawUrl, filename) =>
|
|
|
+ downloadAssetByUrl(rawUrl, filename, '视频下载中...')
|
|
|
+
|
|
|
+const downloadRecordPreview = () => {
|
|
|
+ if (!recordPreviewPlayUrl.value) return
|
|
|
+ downloadVideoByUrl(recordPreviewPlayUrl.value, buildVideoFilename(recordPreviewItem.value))
|
|
|
+}
|
|
|
+
|
|
|
+const downloadHistoryVideo = (row) => {
|
|
|
+ const url = getHistoryVideoPlayUrl(row)
|
|
|
+ if (!url) {
|
|
|
+ ElMessage.warning('视频尚未生成,暂无法下载')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ downloadVideoByUrl(url, buildVideoFilename(row))
|
|
|
+}
|
|
|
+
|
|
|
+const onHistoryVideoDragStart = (event, row) => {
|
|
|
+ const url = getHistoryVideoPlayUrl(row)
|
|
|
+ if (!url) {
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const filename = buildVideoFilename(row)
|
|
|
+ event.dataTransfer.effectAllowed = 'copy'
|
|
|
+ event.dataTransfer.setData('text/uri-list', url)
|
|
|
+ event.dataTransfer.setData('text/plain', url)
|
|
|
+ try {
|
|
|
+ event.dataTransfer.setData('DownloadURL', `video/mp4:${filename}:${url}`)
|
|
|
+ } catch (_) {
|
|
|
+ /* 部分浏览器不支持 DownloadURL */
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const downloadPreviewFrame = (which) => {
|
|
|
+ const row = recordPreviewItem.value
|
|
|
+ if (!row || !hasHistoryFrameUrl(row, which)) return
|
|
|
+ const url = historyFramePreviewFromField(row, which)
|
|
|
+ const id = getPreviewTaskId(row)
|
|
|
+ const filename = which === 'first' ? `首帧_${id}.jpg` : `尾帧_${id}.jpg`
|
|
|
+ downloadAssetByUrl(url, filename, '图片下载中...')
|
|
|
+}
|
|
|
+
|
|
|
+const openRecordDialog = () => {
|
|
|
+ recordDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const onRecordDialogOpen = () => {
|
|
|
+ loadVideoHistory()
|
|
|
+}
|
|
|
+
|
|
|
+const downloadCurrentVideo = () => {
|
|
|
+ if (!previewVideoUrl.value) return
|
|
|
+ downloadVideoByUrl(previewVideoUrl.value, buildVideoFilename(currentVideoItem.value))
|
|
|
+}
|
|
|
+
|
|
|
+const loadVideoHistory = async () => {
|
|
|
+ try {
|
|
|
+ const params = {
|
|
|
+ page: page.value,
|
|
|
+ limit: pageSize.value,
|
|
|
+ sys_id: userStore.userInfo.nickName,
|
|
|
+ }
|
|
|
+ if (searchInfo.value.trim()) params.search = searchInfo.value.trim()
|
|
|
+ const res = await Getvideolist(params)
|
|
|
+ if (res.code === 0) {
|
|
|
+ historyList.value = Array.isArray(res.data?.list) ? res.data.list : res.data
|
|
|
+ total.value = res.count || 0
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载视频历史失败', error)
|
|
|
+ ElMessage.error('加载历史记录失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ loadModelList()
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ generateSessionId++
|
|
|
+ stopPollingVideoTask()
|
|
|
+ FRAME_KEYS.forEach((key) => {
|
|
|
+ if (framePreviewUrls[key]) URL.revokeObjectURL(framePreviewUrls[key])
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+/** 本页 AI 接口成功:code 为 0 或 1 */
|
|
|
+const isApiOk = (res) => res && (res.code === 0 || res.code === 1)
|
|
|
+
|
|
|
+/** 兼容 data.task 嵌套结构 */
|
|
|
+const getVideoTaskCore = (data) => {
|
|
|
+ if (!data) return null
|
|
|
+ const task = data.task && typeof data.task === 'object' ? data.task : null
|
|
|
+ if (!task) return data
|
|
|
+ const { id: remoteTaskId, ...taskFields } = task
|
|
|
+ return {
|
|
|
+ ...data,
|
|
|
+ ...taskFields,
|
|
|
+ status: task.status || data.status,
|
|
|
+ error: task.error || data.error,
|
|
|
+ web_url: data.web_url || task.web_url || task.video_url || data.video_url,
|
|
|
+ task_id: data.task_id || remoteTaskId,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const isVideoTaskSucceeded = (data) => {
|
|
|
+ const core = getVideoTaskCore(data)
|
|
|
+ if (!core?.web_url) return false
|
|
|
+ const status = String(core.status || core.task_status || '').toLowerCase()
|
|
|
+ if (!status) return true
|
|
|
+ return status === 'succeeded' || status === 'completed' || status === 'success'
|
|
|
+}
|
|
|
+
|
|
|
+const isVideoTaskFailed = (data) => {
|
|
|
+ const core = getVideoTaskCore(data)
|
|
|
+ if (!core) return false
|
|
|
+ const status = String(core.status || core.task_status || '').toLowerCase()
|
|
|
+ if (['failed', 'error', 'cancelled', 'canceled'].includes(status)) return true
|
|
|
+ const err = core.error
|
|
|
+ if (err && (err.message || err.code || typeof err === 'string')) return true
|
|
|
+ if (core.fail_message || core.error_message) return true
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+const getVideoTaskFailMessage = (res, data) => {
|
|
|
+ const core = getVideoTaskCore(data)
|
|
|
+ const msg = [
|
|
|
+ res?.msg,
|
|
|
+ core?.error?.message,
|
|
|
+ core?.error?.code,
|
|
|
+ typeof core?.error === 'string' ? core.error : '',
|
|
|
+ core?.fail_message,
|
|
|
+ core?.error_message,
|
|
|
+ ].find((item) => item != null && String(item).trim())
|
|
|
+ return msg ? String(msg).trim() : '视频生成失败'
|
|
|
+}
|
|
|
+
|
|
|
+const parseVideoTaskResponse = (res) => {
|
|
|
+ const data = getVideoTaskCore(res?.data)
|
|
|
+ const resMsg = res?.msg ? String(res.msg).trim() : ''
|
|
|
+ if (data) {
|
|
|
+ if (isVideoTaskSucceeded(data)) {
|
|
|
+ return { state: 'success', data, message: resMsg }
|
|
|
+ }
|
|
|
+ if (isVideoTaskFailed(data)) {
|
|
|
+ return { state: 'failed', data, message: getVideoTaskFailMessage(res, res?.data) }
|
|
|
+ }
|
|
|
+ if (resMsg && /失败|failed|error|limit/i.test(resMsg)) {
|
|
|
+ return { state: 'failed', data, message: resMsg }
|
|
|
+ }
|
|
|
+ return { state: 'pending', data, message: resMsg }
|
|
|
+ }
|
|
|
+ if (resMsg && /失败|failed|error|limit/i.test(resMsg)) {
|
|
|
+ return { state: 'failed', data: null, message: resMsg }
|
|
|
+ }
|
|
|
+ return { state: 'pending', data: null, message: resMsg }
|
|
|
+}
|
|
|
+
|
|
|
+const mergeTaskFromApiResponse = (row, res) => {
|
|
|
+ const data = getVideoTaskCore(res?.data) || {}
|
|
|
+ const { task: _nestedTask, ...taskData } = data
|
|
|
+ const parsed = parseVideoTaskResponse(res)
|
|
|
+ const taskQueryMsg = res?.msg ? String(res.msg).trim() : ''
|
|
|
+ return {
|
|
|
+ ...row,
|
|
|
+ ...taskData,
|
|
|
+ id: row?.id,
|
|
|
+ task_id: taskData.task_id || row?.task_id || row?.video_id,
|
|
|
+ status: taskData.status || row?.status || (parsed.state === 'failed' ? 'failed' : row?.status),
|
|
|
+ web_url: parsed.state === 'success'
|
|
|
+ ? (taskData.web_url || row?.web_url || '')
|
|
|
+ : (parsed.state === 'failed' ? '' : row?.web_url || ''),
|
|
|
+ task_query_msg: taskQueryMsg,
|
|
|
+ fail_message: parsed.state === 'failed' ? parsed.message : (row?.fail_message || ''),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryVideoStatus = (row) => {
|
|
|
+ if (row?.web_url) return 'success'
|
|
|
+ const core = getVideoTaskCore(row) || row
|
|
|
+ if (isVideoTaskFailed(core)) return 'failed'
|
|
|
+ if (core?.task_query_msg && /失败|failed|error|limit/i.test(String(core.task_query_msg))) return 'failed'
|
|
|
+ return 'pending'
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryVideoStatusLabel = (row) => {
|
|
|
+ const status = getHistoryVideoStatus(row)
|
|
|
+ if (status === 'success') return '已完成'
|
|
|
+ if (status === 'failed') return '失败'
|
|
|
+ return '生成中'
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryVideoStatusType = (row) => {
|
|
|
+ const status = getHistoryVideoStatus(row)
|
|
|
+ if (status === 'success') return 'success'
|
|
|
+ if (status === 'failed') return 'danger'
|
|
|
+ return 'warning'
|
|
|
+}
|
|
|
+
|
|
|
+const getVideoRowFailMessage = (row) => {
|
|
|
+ const core = getVideoTaskCore(row) || row
|
|
|
+ return (
|
|
|
+ core?.task_query_msg ||
|
|
|
+ core?.fail_message ||
|
|
|
+ core?.error_message ||
|
|
|
+ core?.error?.message ||
|
|
|
+ core?.error?.code ||
|
|
|
+ (typeof core?.error === 'string' ? core.error : '') ||
|
|
|
+ '视频生成失败,请稍后重试'
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const patchHistoryRowByTaskId = (taskId, patch) => {
|
|
|
+ const idx = historyList.value.findIndex(
|
|
|
+ (item) => (item.task_id || item.video_id) === taskId
|
|
|
+ )
|
|
|
+ if (idx >= 0) {
|
|
|
+ historyList.value[idx] = { ...historyList.value[idx], ...patch }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+let pollVideoTimer = null
|
|
|
+let pollVideoInterval = null
|
|
|
+let pollVideoCount = 0
|
|
|
+const POLL_FIRST_DELAY_MS = 10000
|
|
|
+const POLL_INTERVAL_MS = 6000
|
|
|
+const POLL_MAX_COUNT = 80
|
|
|
+
|
|
|
+const stopPollingVideoTask = () => {
|
|
|
+ if (pollVideoTimer) {
|
|
|
+ clearTimeout(pollVideoTimer)
|
|
|
+ pollVideoTimer = null
|
|
|
+ }
|
|
|
+ if (pollVideoInterval) {
|
|
|
+ clearInterval(pollVideoInterval)
|
|
|
+ pollVideoInterval = null
|
|
|
+ }
|
|
|
+ pollVideoCount = 0
|
|
|
+}
|
|
|
+
|
|
|
+const startPollingVideoTask = (taskId, meta = {}, sessionId = generateSessionId) => {
|
|
|
+ if (!taskId) return
|
|
|
+ stopPollingVideoTask()
|
|
|
+ isGenerating.value = true
|
|
|
+ pollVideoCount = 0
|
|
|
+
|
|
|
+ const finishPolling = (done) => {
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ stopPollingVideoTask()
|
|
|
+ clearGeneratingState()
|
|
|
+ if (!done) ElMessage.info('视频生成时间较长,可在「视频生成记录」中查看')
|
|
|
+ }
|
|
|
+
|
|
|
+ let lastPollFailMessage = ''
|
|
|
+
|
|
|
+ const handlePollResult = async (result) => {
|
|
|
+ if (result === 'cancelled') return
|
|
|
+ if (result === 'pending') return
|
|
|
+ if (result === 'success') {
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ ElMessage.success('视频生成成功')
|
|
|
+ await loadVideoHistory()
|
|
|
+ finishPolling(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (result === 'failed') {
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ ElMessage.error(lastPollFailMessage || '视频生成失败')
|
|
|
+ patchHistoryRowByTaskId(taskId, { status: 'failed', fail_message: lastPollFailMessage, web_url: '' })
|
|
|
+ await loadVideoHistory()
|
|
|
+ finishPolling(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ finishPolling(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ const pollOnce = async () => {
|
|
|
+ if (sessionId !== generateSessionId) return 'cancelled'
|
|
|
+ pollVideoCount++
|
|
|
+ if (pollVideoCount > POLL_MAX_COUNT) return 'timeout'
|
|
|
+ try {
|
|
|
+ const res = await Get_ImgToVideo(
|
|
|
+ { task_id: taskId, sys_id: userStore.userInfo.nickName },
|
|
|
+ { donNotShowLoading: true, skipErrorMessage: true }
|
|
|
+ )
|
|
|
+ if (sessionId !== generateSessionId) return 'cancelled'
|
|
|
+ const parsed = parseVideoTaskResponse(res)
|
|
|
+ if (parsed.state === 'success') {
|
|
|
+ setPreviewVideo({
|
|
|
+ task_id: taskId,
|
|
|
+ video_id: parsed.data.video_id || taskId,
|
|
|
+ web_url: parsed.data.web_url,
|
|
|
+ prompt: meta.prompt ?? form.prompt,
|
|
|
+ model: meta.model ?? form.model,
|
|
|
+ seconds: meta.seconds ?? form.seconds,
|
|
|
+ size: meta.size ?? form.size
|
|
|
+ })
|
|
|
+ return 'success'
|
|
|
+ }
|
|
|
+ if (parsed.state === 'failed') {
|
|
|
+ lastPollFailMessage = parsed.message
|
|
|
+ return 'failed'
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('轮询视频状态失败', error)
|
|
|
+ }
|
|
|
+ return 'pending'
|
|
|
+ }
|
|
|
+
|
|
|
+ pollVideoTimer = setTimeout(async () => {
|
|
|
+ pollVideoTimer = null
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ const first = await pollOnce()
|
|
|
+ if (first !== 'pending') {
|
|
|
+ await handlePollResult(first)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ pollVideoInterval = setInterval(async () => {
|
|
|
+ if (sessionId !== generateSessionId) {
|
|
|
+ stopPollingVideoTask()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await handlePollResult(await pollOnce())
|
|
|
+ }, POLL_INTERVAL_MS)
|
|
|
+ }, POLL_FIRST_DELAY_MS)
|
|
|
+}
|
|
|
+
|
|
|
+const generateVideo = async () => {
|
|
|
+ if (!form.prompt?.trim() && !hasAnyFrameImage()) {
|
|
|
+ ElMessage.warning('请输入提示词或上传首帧/尾帧')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!form.model) {
|
|
|
+ ElMessage.warning('请选择模型')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const sessionId = ++generateSessionId
|
|
|
+ const wasGenerating = isGenerating.value
|
|
|
+ if (!wasGenerating) {
|
|
|
+ previewVideoUrl.value = ''
|
|
|
+ currentVideoItem.value = null
|
|
|
+ isGenerating.value = true
|
|
|
+ }
|
|
|
+ let pollingStarted = false
|
|
|
+ try {
|
|
|
+ const firstFile = getFrameRawFile('first')
|
|
|
+ const lastFile = getFrameRawFile('last')
|
|
|
+ let first_image = firstFile ? await fileToBase64Compressed(firstFile) : ''
|
|
|
+ let last_image = lastFile ? await fileToBase64Compressed(lastFile) : ''
|
|
|
+ if (!first_image && form.first_image_url) {
|
|
|
+ first_image = await fetchImageAsBase64(form.first_image_url)
|
|
|
+ }
|
|
|
+ if (!last_image && form.last_image_url) {
|
|
|
+ last_image = await fetchImageAsBase64(form.last_image_url)
|
|
|
+ }
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ const params = {
|
|
|
+ prompt: (form.prompt || '').trim(),
|
|
|
+ model: form.model,
|
|
|
+ seconds: form.seconds,
|
|
|
+ size: form.size,
|
|
|
+ sys_id: userStore.userInfo.nickName,
|
|
|
+ }
|
|
|
+ if (first_image) params.first_image = first_image
|
|
|
+ if (last_image) params.last_image = last_image
|
|
|
+
|
|
|
+ const createRes = await Create_ImgToVideo(params, { skipErrorMessage: true, donNotShowLoading: true })
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ const taskId = createRes?.data?.task_id || createRes?.data?.video_id
|
|
|
+ if (isApiOk(createRes) && taskId) {
|
|
|
+ if (wasGenerating) {
|
|
|
+ ElMessage.success('已提交新任务,上一任务可在「视频生成记录」中查看')
|
|
|
+ } else {
|
|
|
+ ElMessage.success(createRes.msg || '任务创建成功,正在生成视频...')
|
|
|
+ }
|
|
|
+ pollingStarted = true
|
|
|
+ startPollingVideoTask(taskId, {
|
|
|
+ prompt: params.prompt,
|
|
|
+ model: params.model,
|
|
|
+ seconds: params.seconds,
|
|
|
+ size: params.size
|
|
|
+ }, sessionId)
|
|
|
+ } else {
|
|
|
+ ElMessage.error('创建视频任务失败:' + (createRes?.msg || '未知错误'))
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (sessionId !== generateSessionId) return
|
|
|
+ console.error('视频生成过程中发生错误', error)
|
|
|
+ ElMessage.error('视频生成失败,请检查网络连接后重试')
|
|
|
+ } finally {
|
|
|
+ if (sessionId === generateSessionId && !pollingStarted && !wasGenerating) {
|
|
|
+ clearGeneratingState()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const optimizePrompt = async () => {
|
|
|
+ if (!form.prompt?.trim()) {
|
|
|
+ ElMessage.warning('请先输入提示词')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ isOptimizing.value = true
|
|
|
+ try {
|
|
|
+ const res = await CallAIModelApi(
|
|
|
+ {
|
|
|
+ status_val: '文生文',
|
|
|
+ status_type: 'ImgToVideo',
|
|
|
+ prompt: form.prompt,
|
|
|
+ model: 'gpt-4',
|
|
|
+ sys_id: userStore.userInfo.nickName,
|
|
|
+ },
|
|
|
+ { donNotShowLoading: true }
|
|
|
+ )
|
|
|
+ const content = res?.data?.content ?? res?.content
|
|
|
+ if (res?.code === 0 && content) {
|
|
|
+ form.prompt = content
|
|
|
+ ElMessage.success('提示词优化完成')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(res?.msg || '优化失败')
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[优化提示词] 错误:', e)
|
|
|
+ ElMessage.error('优化失败: ' + (e?.message || '未知错误'))
|
|
|
+ } finally {
|
|
|
+ isOptimizing.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleSizeChange = (size) => {
|
|
|
+ pageSize.value = size
|
|
|
+ loadVideoHistory()
|
|
|
+}
|
|
|
+
|
|
|
+const handleCurrentChange = (current) => {
|
|
|
+ page.value = current
|
|
|
+ loadVideoHistory()
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.video-generation-page {
|
|
|
+ padding: 6px 10px;
|
|
|
+ height: calc(100vh - 200px);
|
|
|
+ max-height: calc(100vh - 200px);
|
|
|
+ max-height: calc(100vh - 200px);
|
|
|
+ min-height: 480px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.two-column-layout {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.left-column {
|
|
|
+ flex: 0 0 380px;
|
|
|
+ min-width: 280px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.right-column {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.config-card,
|
|
|
+.result-card {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ min-height: 0;
|
|
|
+
|
|
|
+ :deep(.el-card__body) {
|
|
|
+ padding: 0;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.config-card :deep(.el-card__header) {
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+
|
|
|
+.result-card :deep(.el-card__header) {
|
|
|
+ padding: 10px 14px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.result-card :deep(.el-card__body) {
|
|
|
+ padding: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-card :deep(.record-btn-header.el-button) {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: auto;
|
|
|
+ padding: 6px 14px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ border-radius: 8px;
|
|
|
+ color: #409eff !important;
|
|
|
+ background: #ecf5ff !important;
|
|
|
+ border: 1px solid #d9ecff !important;
|
|
|
+ box-shadow: none;
|
|
|
+
|
|
|
+ &:hover,
|
|
|
+ &:focus {
|
|
|
+ background: #409eff !important;
|
|
|
+ color: #fff !important;
|
|
|
+ border-color: #409eff !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.config-body {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.config-form {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ padding: 6px 10px;
|
|
|
+ overflow-y: auto;
|
|
|
+ overflow-x: hidden;
|
|
|
+
|
|
|
+ :deep(.el-form-item) {
|
|
|
+ margin-bottom: 4px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.upload-row-inner {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-cell {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ max-width: 140px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-cell-inner {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+
|
|
|
+ .optional {
|
|
|
+ color: #909399;
|
|
|
+ font-weight: normal;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.image-uploader.square {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(.el-upload) {
|
|
|
+ width: 100%;
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-upload-dragger) {
|
|
|
+ width: 100%;
|
|
|
+ padding: 0;
|
|
|
+ border: none;
|
|
|
+ background: transparent;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ &.is-dragover {
|
|
|
+ .upload-placeholder,
|
|
|
+ .uploaded-preview {
|
|
|
+ border-color: #409eff;
|
|
|
+ background: #ecf5ff;
|
|
|
+ box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-placeholder,
|
|
|
+ .uploaded-preview {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 1;
|
|
|
+ height: auto !important;
|
|
|
+ min-height: 80px;
|
|
|
+ max-height: 130px;
|
|
|
+ max-width: 130px;
|
|
|
+ margin: 0 auto;
|
|
|
+ border: 1px dashed #d9d9d9;
|
|
|
+ border-radius: 6px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+ background: #fafafa;
|
|
|
+ overflow: hidden;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #409eff;
|
|
|
+ background: #f0f9ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-placeholder {
|
|
|
+ color: #8c939d;
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 11px;
|
|
|
+ margin-top: 2px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .uploaded-preview {
|
|
|
+ position: relative;
|
|
|
+
|
|
|
+ .preview-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-mask {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ background: rgba(0, 0, 0, 0.4);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #fff;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s;
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 11px;
|
|
|
+ margin-top: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .swap-icon {
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover .upload-mask {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.upload-remove-btn {
|
|
|
+ position: absolute;
|
|
|
+ top: 4px;
|
|
|
+ right: 4px;
|
|
|
+ z-index: 3;
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ padding: 0;
|
|
|
+ border: none;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ line-height: 1;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f56c6c;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.image-error {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f5f7fa;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-form-item {
|
|
|
+ margin-bottom: 4px;
|
|
|
+
|
|
|
+ :deep(.el-form-item__label) {
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ text-align: left;
|
|
|
+ padding-left: 0;
|
|
|
+ line-height: 1.4;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-block {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-wrapper {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 100%;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+
|
|
|
+ :deep(.el-textarea__inner) {
|
|
|
+ border: none;
|
|
|
+ border-radius: 0;
|
|
|
+ box-shadow: none;
|
|
|
+ padding-bottom: 28px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-input__count) {
|
|
|
+ background: #fff;
|
|
|
+ padding-left: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-textarea {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(textarea) {
|
|
|
+ resize: none;
|
|
|
+ min-height: 100px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-actions-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+ padding: 8px 0;
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.config-row-compact {
|
|
|
+ margin-bottom: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.config-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.config-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.model-item {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.model-select {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(.el-select__wrapper) {
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ background: #fafbfc;
|
|
|
+ box-shadow: none;
|
|
|
+ min-height: 36px;
|
|
|
+ padding: 6px 14px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #c0c4cc;
|
|
|
+ background: #fff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-select__wrapper.is-focused) {
|
|
|
+ border-color: #409eff;
|
|
|
+ box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
|
|
|
+ background: #fff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.size-item {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.size-group-options {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.size-chip {
|
|
|
+ padding: 2px 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+ background: #f5f7fa;
|
|
|
+ color: #606266;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ transition: all 0.2s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ background: #ecf5ff;
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.result-area {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 320px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f9fafb;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.result-preview {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-video {
|
|
|
+ width: 100%;
|
|
|
+ max-height: min(52vh, 480px);
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #000;
|
|
|
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.result-meta {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 720px;
|
|
|
+ text-align: center;
|
|
|
+ padding: 0 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-meta-prompt {
|
|
|
+ margin: 0 0 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.result-meta-info {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.result-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.result-empty {
|
|
|
+ text-align: center;
|
|
|
+ color: #909399;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .empty-icon {
|
|
|
+ color: #dcdfe6;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #c0c4cc;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.generate-btn-center {
|
|
|
+ margin-top: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-loading {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ color: #606266;
|
|
|
+
|
|
|
+ .loading-icon {
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .result-loading-next-btn {
|
|
|
+ margin-top: 4px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.history-ref-thumb {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: zoom-in;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ :deep(.el-image__inner) {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.history-thumb-preview {
|
|
|
+ display: inline-block;
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+
|
|
|
+.history-video-cell {
|
|
|
+ position: relative;
|
|
|
+ display: inline-block;
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: grab;
|
|
|
+
|
|
|
+ &:active {
|
|
|
+ cursor: grabbing;
|
|
|
+ }
|
|
|
+
|
|
|
+ .history-ref-thumb {
|
|
|
+ cursor: grab;
|
|
|
+ pointer-events: none;
|
|
|
+ user-select: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.history-video-placeholder {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #f0f2f5;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.history-video-play-badge {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: rgba(0, 0, 0, 0.35);
|
|
|
+ color: #fff;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.history-ref-empty {
|
|
|
+ color: #c0c4cc;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.history-prompt-cell {
|
|
|
+ display: block;
|
|
|
+ max-width: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #606266;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.history-thumb-fallback {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f0f2f5;
|
|
|
+ color: #909399;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.history-frame-thumbs {
|
|
|
+ display: inline-flex;
|
|
|
+ gap: 4px;
|
|
|
+ justify-content: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+
|
|
|
+.record-dialog-pagination {
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin-top: 12px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-body {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-meta-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: auto minmax(200px, 2.4fr) 72px 60px minmax(150px, 1fr);
|
|
|
+ gap: 10px 20px;
|
|
|
+ padding: 12px 14px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ align-items: start;
|
|
|
+}
|
|
|
+
|
|
|
+.meta-grid-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.meta-grid-item-status {
|
|
|
+ width: max-content;
|
|
|
+ max-width: 76px;
|
|
|
+}
|
|
|
+
|
|
|
+.meta-grid-item-model {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.meta-grid-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+
|
|
|
+.meta-grid-value {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-task-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: -6px;
|
|
|
+ padding: 0 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-task-id-text {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ font-family: Consolas, Monaco, monospace;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-stage {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ align-items: stretch;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-stage.has-frames .record-preview-video-panel {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-video-panel {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ min-height: min(42vh, 400px);
|
|
|
+ max-height: min(54vh, 460px);
|
|
|
+ border-radius: 10px;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ background: #0f0f10;
|
|
|
+ overflow: hidden;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-frames-side {
|
|
|
+ width: 112px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.frame-side-card {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 8px;
|
|
|
+ background: #fafbfc;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.frame-side-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.frame-side-thumb {
|
|
|
+ width: 100%;
|
|
|
+ height: 68px;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ background: #f5f7fa;
|
|
|
+ cursor: zoom-in;
|
|
|
+}
|
|
|
+
|
|
|
+.frame-thumb-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+
|
|
|
+ :deep(.el-image__inner) {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-alert {
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-prompt-block {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ padding-top: 4px;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-section-title {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-prompt-scroll {
|
|
|
+ padding: 12px 14px;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.75;
|
|
|
+ color: #606266;
|
|
|
+ background: #fafbfc;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 8px;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+
|
|
|
+.media-frame-video {
|
|
|
+ position: relative;
|
|
|
+ background: #000;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-video-wrap {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.video-area-loading {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 10px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #e5eaf3;
|
|
|
+}
|
|
|
+
|
|
|
+.media-frame-video .video-area-loading,
|
|
|
+.media-frame-video .media-generating.video-area-loading {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 120px;
|
|
|
+ background: rgba(0, 0, 0, 0.75);
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-video-wrap .video-area-loading {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 1;
|
|
|
+ background: rgba(0, 0, 0, 0.55);
|
|
|
+}
|
|
|
+
|
|
|
+.media-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+
|
|
|
+ :deep(.el-image__inner) {
|
|
|
+ max-width: 100%;
|
|
|
+ max-height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.media-fallback {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 120px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.media-generating {
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.media-failed {
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #f56c6c;
|
|
|
+ background: #fef0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-fail,
|
|
|
+.record-preview-task-msg {
|
|
|
+ margin: 0;
|
|
|
+ padding: 8px 10px;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.5;
|
|
|
+ border-radius: 4px;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-task-msg {
|
|
|
+ color: #606266;
|
|
|
+ background: #f4f4f5;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-task-msg.is-fail,
|
|
|
+.record-preview-fail {
|
|
|
+ color: #f56c6c;
|
|
|
+ background: #fef0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-video {
|
|
|
+ width: 100%;
|
|
|
+ max-height: min(54vh, 460px);
|
|
|
+ object-fit: contain;
|
|
|
+ background: #000;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-footer {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+@media screen and (max-width: 900px) {
|
|
|
+ .record-preview-meta-grid {
|
|
|
+ grid-template-columns: auto 1fr 1fr;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-grid-item-model {
|
|
|
+ grid-column: span 2;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media screen and (max-width: 768px) {
|
|
|
+ .record-preview-stage {
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+
|
|
|
+ .record-preview-frames-side {
|
|
|
+ width: 100%;
|
|
|
+ flex-direction: row;
|
|
|
+ }
|
|
|
+
|
|
|
+ .frame-side-card {
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .record-preview-video-panel {
|
|
|
+ min-height: 220px;
|
|
|
+ max-height: 40vh;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media screen and (max-width: 992px) {
|
|
|
+ .two-column-layout {
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+
|
|
|
+ .left-column {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ max-height: 50vh;
|
|
|
+ min-width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .right-column {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 280px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+/* 记录弹窗:teleport 到 body,样式须写全局;上下居中,不贴顶 */
|
|
|
+.el-overlay-dialog:has(.video-record-dialog) {
|
|
|
+ display: flex !important;
|
|
|
+ align-items: center !important;
|
|
|
+ justify-content: center !important;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog {
|
|
|
+ display: flex !important;
|
|
|
+ flex-direction: column !important;
|
|
|
+ overflow: hidden !important;
|
|
|
+ width: 90% !important;
|
|
|
+ height: 90vh !important;
|
|
|
+ max-height: 90vh !important;
|
|
|
+ margin: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .el-dialog__header {
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .el-dialog__body {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ box-sizing: border-box;
|
|
|
+ padding-top: 8px;
|
|
|
+ padding-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .video-record-dialog-inner {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .video-record-table-wrap {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .video-record-table-wrap .el-table {
|
|
|
+ width: 100% !important;
|
|
|
+ height: 100% !important;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .video-record-table-wrap .el-table__inner-wrapper {
|
|
|
+ height: 100% !important;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .video-record-table-wrap .el-table__body-wrapper {
|
|
|
+ overflow-y: scroll !important;
|
|
|
+ overflow-x: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-dialog .record-dialog-pagination {
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-preview-dialog .el-dialog__header {
|
|
|
+ padding-bottom: 8px;
|
|
|
+ margin-right: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-preview-dialog .el-dialog__body {
|
|
|
+ padding-top: 4px;
|
|
|
+ padding-bottom: 12px;
|
|
|
+ max-height: calc(88vh - 132px);
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-preview-dialog .el-dialog__footer {
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-prompt-popover {
|
|
|
+ padding: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.video-record-prompt-popover .history-prompt-popover-body {
|
|
|
+ max-height: 180px;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 10px 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: #303133;
|
|
|
+ word-break: break-word;
|
|
|
+ white-space: pre-wrap;
|
|
|
+}
|
|
|
+
|
|
|
+/* 图片放大预览:层级高于所有 dialog,点击遮罩空白处关闭 */
|
|
|
+.el-image-viewer__wrapper {
|
|
|
+ z-index: 10001 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.el-image-viewer__mask {
|
|
|
+ cursor: zoom-out;
|
|
|
+ pointer-events: auto;
|
|
|
+}
|
|
|
+</style>
|