m0_70156489 9 hours ago
parent
commit
7090865a47

+ 12 - 0
src/api/mes/job.js

@@ -1200,6 +1200,15 @@ export const getPreviewFolders = (params) => {
     params
   })
 }
+//获取图片历史记录【文生图】
+export const Gettexttoimagelist = (params) => {
+  return service({
+    url: '/mes_server/work_order/Gettexttoimagelist',
+    method: 'get',
+    params
+  })
+}
+
 //获取视频列表
 export const Getvideolist = (params) => {
   return service({
@@ -1225,6 +1234,7 @@ export const Get_ImgToVideo = (params, config = {}) => {
   })
 }
 
+//已废弃掉
 export const video = (params) => {
   return service({
     url: '/mes_server/work_order/video',
@@ -1232,6 +1242,7 @@ export const video = (params) => {
     params
   })
 }
+//已废弃掉
 export const videoContent = (params) => {
   return service({
     url: '/mes_server/work_order/videoContent',
@@ -1239,6 +1250,7 @@ export const videoContent = (params) => {
     params
   })
 }
+
 export const GetHttpUrl = (params) => {
   return service({
     url: '/mes_server/work_order/GetHttpUrl',

+ 686 - 223
src/view/Product/ProductImageGeneration.vue

@@ -13,6 +13,7 @@
                   <span class="upload-label">{{ item.label }}<span v-if="item.required" class="required"> *</span></span>
                   <el-upload
                     class="image-uploader square"
+                    drag
                     :action="`${uploadPath}/fileUploadAndDownload/upload`"
                     :headers="{ 'x-token': userStore.token, 'x-user-id': userStore.userInfo?.ID ?? '' }"
                     :show-file-list="false"
@@ -181,8 +182,10 @@
             </div>
             <div v-else-if="isGenerating" v-show="!promptHistoryVisible" class="result-loading">
               <el-icon :size="48" class="loading-icon is-loading"><Loading /></el-icon>
-              <p>正在生成效果图,请稍候...</p>
-              <el-button type="default" size="small" @click="cancelGenerate">取消生成</el-button>
+              <p>图片生成中,请稍等...</p>
+              <el-button type="primary" plain class="result-loading-next-btn" @click="generateImage">
+                继续生成下一个图片
+              </el-button>
             </div>
             <div v-else v-show="!promptHistoryVisible" class="result-empty">
               <el-icon :size="60" class="empty-icon"><Picture /></el-icon>
@@ -197,72 +200,250 @@
       </div>
     </div>
 
-    <!-- 图片生成记录弹窗:align-center 视口垂直居中;宽度随屏宽自适应 -->
     <el-dialog
       v-model="recordDialogVisible"
       title="图片生成记录"
       align-center
-      width="min(92vw, 960px)"
-      class="record-dialog"
+      style="width: 90%;height: 90%;"
+      class="product-record-dialog"
+      destroy-on-close
       @open="onRecordDialogOpen"
     >
-      <div class="record-dialog-inner">
-        <div v-loading="recordLoading" class="record-list">
-        <div v-if="recordList.length === 0 && !recordLoading" class="record-empty">暂无生成记录</div>
-        <div v-for="item in recordList" :key="item.id" class="record-item">
-          <div class="record-images">
-            <div class="record-img-cell">
-              <span class="record-img-label">产品图</span>
-              <div class="record-img-frame">
-                <el-image v-if="item.product_img" :src="formatImageUrl(item.product_img)" fit="contain" class="record-img-fill" :preview-src-list="[formatImageUrl(item.product_img)]" preview-teleported>
-                  <template #error><div class="record-img-error"><el-icon><Picture /></el-icon></div></template>
-                </el-image>
-                <div v-else class="record-img-placeholder">-</div>
-              </div>
-            </div>
-            <div class="record-img-cell">
-              <span class="record-img-label">参考图</span>
-              <div class="record-img-frame">
-                <el-image v-if="item.reference_image" :src="formatImageUrl(item.reference_image)" fit="contain" class="record-img-fill" :preview-src-list="[formatImageUrl(item.reference_image)]" preview-teleported>
-                  <template #error><div class="record-img-error"><el-icon><Picture /></el-icon></div></template>
-                </el-image>
-                <div v-else class="record-img-placeholder">-</div>
-              </div>
-            </div>
-            <div class="record-img-cell">
-              <span class="record-img-label">生成图</span>
-              <div class="record-img-frame">
-                <el-image v-if="item.generated_image" :src="formatImageUrl(item.generated_image)" fit="contain" class="record-img-fill" :preview-src-list="[formatImageUrl(item.generated_image)]" preview-teleported>
-                  <template #error><div class="record-img-error"><el-icon><Picture /></el-icon></div></template>
-                </el-image>
-                <div v-else class="record-img-placeholder">生成中/暂无</div>
-              </div>
-            </div>
-          </div>
-          <div class="record-meta">
-            <div class="record-prompt"><span class="meta-label">提示词:</span>{{ item.prompt || '-' }}</div>
-            <div class="record-info">
-              <span>模型:{{ item.model || '-' }}</span>
-              <span>尺寸:{{ item.size || '-' }}</span>
-              <span>模式:{{ item.status_val || '-' }}</span>
-              <span>时间:{{ item.createTime || '-' }}</span>
-            </div>
-          </div>
-        </div>
+      <div class="product-record-dialog-inner">
+        <div class="product-record-table-wrap">
+          <el-table v-loading="recordLoading" :data="recordList" stripe size="small" style="width: 100%;height: 100%;">
+            <el-table-column align="center" prop="id" label="ID" width="70" />
+            <el-table-column label="产品图" width="80" align="center">
+              <template #default="scope">
+                <el-image
+                  v-if="getRecordProductUrl(scope.row)"
+                  :src="formatImageUrl(getRecordProductUrl(scope.row))"
+                  fit="cover"
+                  class="history-ref-thumb"
+                  :preview-src-list="[formatImageUrl(getRecordProductUrl(scope.row))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <span v-else class="history-ref-empty">-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="参考图" width="80" align="center">
+              <template #default="scope">
+                <el-image
+                  v-if="getRecordReferenceUrl(scope.row)"
+                  :src="formatImageUrl(getRecordReferenceUrl(scope.row))"
+                  fit="cover"
+                  class="history-ref-thumb"
+                  :preview-src-list="[formatImageUrl(getRecordReferenceUrl(scope.row))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <span v-else class="history-ref-empty">-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="生成图" width="96" align="center">
+              <template #default="scope">
+                <el-image
+                  v-if="getRecordGeneratedUrl(scope.row)"
+                  :src="formatImageUrl(getRecordGeneratedUrl(scope.row))"
+                  fit="cover"
+                  class="history-ref-thumb"
+                  :preview-src-list="[formatImageUrl(getRecordGeneratedUrl(scope.row))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <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="getRecordPrompt(scope.row)"
+                  placement="top-start"
+                  trigger="hover"
+                  :show-after="200"
+                  :hide-after="80"
+                  :width="300"
+                  teleported
+                  popper-class="product-record-prompt-popover"
+                >
+                  <template #reference>
+                    <span class="history-prompt-cell">{{ getRecordPrompt(scope.row) }}</span>
+                  </template>
+                  <div class="history-prompt-popover-body">{{ getRecordPrompt(scope.row) }}</div>
+                </el-popover>
+                <span v-else>-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="尺寸" width="100" align="center">
+              <template #default="scope">{{ scope.row.size || '-' }}</template>
+            </el-table-column>
+            <el-table-column prop="createTime" label="生成时间" width="168" />
+            <el-table-column label="状态" width="88" align="center">
+              <template #default="scope">
+                <el-tag size="small" :type="getRecordGeneratedUrl(scope.row) ? 'success' : 'warning'">
+                  {{ getRecordGeneratedUrl(scope.row) ? '已完成' : '生成中' }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="160" fixed="right">
+              <template #default="scope">
+                <el-button
+                  size="small"
+                  type="primary"
+                  link
+                  :disabled="!getRecordGeneratedUrl(scope.row)"
+                  @click="viewRecordImage(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 v-if="recordTotal > 0" class="record-pagination">
+        <div class="gva-pagination record-dialog-pagination">
           <el-pagination
             v-model:current-page="recordPage"
-            :page-size="recordPageSize"
+            v-model:page-size="recordPageSize"
+            :page-sizes="[10, 30, 50, 100]"
+            layout="total, sizes, prev, pager, next, jumper"
             :total="recordTotal"
-            layout="total, prev, pager, next"
-            small
-            background
+            @size-change="handleRecordSizeChange"
             @current-change="loadRecordList"
           />
         </div>
       </div>
     </el-dialog>
+
+    <el-dialog
+      v-model="recordPreviewVisible"
+      title="图片预览"
+      align-center
+      width="min(96vw, 1180px)"
+      class="product-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="getRecordGeneratedUrl(recordPreviewItem)" 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">{{ recordPreviewItem.status_val || '-' }}</span>
+          </div>
+          <div class="meta-grid-item">
+            <span class="meta-grid-label">生成时间</span>
+            <span class="meta-grid-value">{{ recordPreviewItem.createTime || '-' }}</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 has-side">
+          <div class="record-preview-image-panel">
+            <el-image
+              v-if="getRecordGeneratedUrl(recordPreviewItem)"
+              :src="formatImageUrl(getRecordGeneratedUrl(recordPreviewItem))"
+              fit="contain"
+              class="record-preview-main-image"
+              :preview-src-list="[formatImageUrl(getRecordGeneratedUrl(recordPreviewItem))]"
+              preview-teleported
+              hide-on-click-modal
+              :z-index="IMAGE_PREVIEW_Z_INDEX"
+            />
+            <div v-else class="media-fallback media-generating">
+              <el-icon :size="32" class="is-loading"><Loading /></el-icon>
+              <span>图片生成中,请稍等...</span>
+            </div>
+          </div>
+          <aside class="record-preview-frames-side">
+            <div class="frame-side-card">
+              <span class="frame-side-label">产品图</span>
+              <div class="frame-side-thumb">
+                <el-image
+                  v-if="getRecordProductUrl(recordPreviewItem)"
+                  :src="formatImageUrl(getRecordProductUrl(recordPreviewItem))"
+                  fit="cover"
+                  class="frame-thumb-image"
+                  :preview-src-list="[formatImageUrl(getRecordProductUrl(recordPreviewItem))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <div v-else class="frame-side-empty">-</div>
+              </div>
+              <el-button
+                v-if="getRecordProductUrl(recordPreviewItem)"
+                type="primary"
+                link
+                size="small"
+                @click="downloadPreviewImage('product')"
+              >下载</el-button>
+            </div>
+            <div class="frame-side-card">
+              <span class="frame-side-label">参考图</span>
+              <div class="frame-side-thumb">
+                <el-image
+                  v-if="getRecordReferenceUrl(recordPreviewItem)"
+                  :src="formatImageUrl(getRecordReferenceUrl(recordPreviewItem))"
+                  fit="cover"
+                  class="frame-thumb-image"
+                  :preview-src-list="[formatImageUrl(getRecordReferenceUrl(recordPreviewItem))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <div v-else class="frame-side-empty">-</div>
+              </div>
+              <el-button
+                v-if="getRecordReferenceUrl(recordPreviewItem)"
+                type="primary"
+                link
+                size="small"
+                @click="downloadPreviewImage('reference')"
+              >下载</el-button>
+            </div>
+          </aside>
+        </div>
+
+        <div class="record-preview-prompt-block">
+          <div class="record-preview-section-title">提示词</div>
+          <div class="record-preview-prompt-scroll">
+            {{ getRecordPrompt(recordPreviewItem) || '-' }}
+          </div>
+        </div>
+      </div>
+      <template #footer>
+        <div class="record-preview-footer">
+          <el-button type="primary" plain size="small" @click="regenerateFromHistory(recordPreviewItem)">重新生成</el-button>
+          <el-button
+            type="primary"
+            size="small"
+            :disabled="!recordPreviewItem || !getRecordGeneratedUrl(recordPreviewItem)"
+            @click="downloadPreviewImage('result')"
+          >下载生成图</el-button>
+          <el-button size="small" @click="recordPreviewVisible = false">关闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -475,8 +656,11 @@ const usePrompt = (text) => {
 }
 watch(promptHistoryVisible, (v) => { if (v) loadPromptHistory() })
 
-// 图片生成记录(接口分页:page + limit,每页 2 条)
+// 图片生成记录
 const recordDialogVisible = ref(false)
+const IMAGE_PREVIEW_Z_INDEX = 10001
+const recordPreviewVisible = ref(false)
+const recordPreviewItem = ref(null)
 const recordList = ref([])
 const recordLoading = ref(false)
 const recordPage = ref(1)
@@ -509,7 +693,7 @@ const loadRecordList = async () => {
     } else if (raw && Array.isArray(raw.list)) {
       list = raw.list
     }
-    recordList.value = list
+    recordList.value = list.map(normalizeRecordRow)
     recordTotal.value = Number(res?.total ?? res?.count ?? raw?.total ?? 0)
   } catch {
     recordList.value = []
@@ -519,6 +703,145 @@ const loadRecordList = async () => {
   }
 }
 
+const handleRecordSizeChange = (size) => {
+  recordPageSize.value = size
+  recordPage.value = 1
+  loadRecordList()
+}
+
+const pickRecordImagePath = (row, ...keys) => {
+  for (const k of keys) {
+    const v = row?.[k]
+    if (v != null && String(v).trim()) return String(v).trim()
+  }
+  return ''
+}
+
+const getRecordProductUrl = (row) =>
+  pickRecordImagePath(row, 'product_img', 'old_img', 'product_image')
+
+const getRecordReferenceUrl = (row) =>
+  pickRecordImagePath(row, 'reference_image', 'template_img', 'ref_img')
+
+const getRecordGeneratedUrl = (row) =>
+  pickRecordImagePath(row, 'generated_image', 'new_img', 'image_url', 'web_url')
+
+const getRecordPrompt = (row) => {
+  const prompt = row?.prompt || ''
+  return prompt ? String(prompt).trim() : ''
+}
+
+const getPreviewTaskId = (row) => row?.task_id || row?.id || '-'
+
+const normalizeRecordRow = (item) => ({
+  ...item,
+  product_img: getRecordProductUrl(item),
+  reference_image: getRecordReferenceUrl(item),
+  generated_image: getRecordGeneratedUrl(item),
+  prompt: getRecordPrompt(item),
+  createTime: item.createTime || item.sys_rq || '',
+})
+
+const viewRecordImage = (row) => {
+  if (!getRecordGeneratedUrl(row)) {
+    ElMessage.warning('图片尚未生成完成')
+    return
+  }
+  recordPreviewItem.value = row
+  recordPreviewVisible.value = true
+}
+
+const downloadPreviewImageAsset = async (rawUrl, filename) => {
+  if (!rawUrl) return
+  const fullUrl = formatImageUrl(rawUrl)
+  const url = fullUrl.startsWith('http') ? fullUrl : (fullUrl.startsWith('/') ? fullUrl : `/${fullUrl}`)
+  try {
+    ElMessage.info('下载中...')
+    const res = await fetch(url, {
+      headers: userStore.token ? { 'x-token': userStore.token } : {},
+      credentials: 'include',
+    })
+    if (!res.ok) throw new Error(res.statusText)
+    const blob = await res.blob()
+    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)
+    }, 100)
+    ElMessage.success('下载完成')
+  } catch (error) {
+    console.error('[下载] 失败:', error)
+    ElMessage.error('下载失败,请重试')
+  }
+}
+
+const downloadPreviewImage = (which) => {
+  const row = recordPreviewItem.value
+  if (!row) return
+  const id = getPreviewTaskId(row)
+  const nameMap = { product: '产品图', reference: '参考图', result: '生成图' }
+  let raw = ''
+  if (which === 'product') raw = getRecordProductUrl(row)
+  else if (which === 'reference') raw = getRecordReferenceUrl(row)
+  else raw = getRecordGeneratedUrl(row)
+  if (!raw) return
+  downloadPreviewImageAsset(raw, `${nameMap[which]}_${id}.png`)
+}
+
+const clearLocalUploadPreview = (key) => {
+  if (previewBlobUrl.value[key]) {
+    URL.revokeObjectURL(previewBlobUrl.value[key])
+    previewBlobUrl.value = { ...previewBlobUrl.value, [key]: '' }
+  }
+  fileSizeMap.value = { ...fileSizeMap.value, [key]: '' }
+  if (key === 'product_img') {
+    productFileRef.value = null
+    productImageSize.value = null
+  }
+  if (key === 'template_img') {
+    templateFileRef.value = null
+    templateImageSize.value = null
+  }
+}
+
+const applyHistoryRowToForm = (row) => {
+  if (!row) return
+  formData.prompt = getRecordPrompt(row)
+  if (row.model) formData.model = row.model
+  if (row.size) {
+    const sizeStr = String(row.size).replace('×', 'x')
+    const preset = sizePresets.find((p) => p.value === sizeStr)
+    if (preset) {
+      selectSize(preset)
+    } else if (sizeStr.includes('x')) {
+      const [w, h] = sizeStr.split('x').map(Number)
+      if (w && h) {
+        formData.customWidth = w
+        formData.customHeight = h
+        formData.size = sizeStr
+      }
+    }
+  }
+  clearLocalUploadPreview('product_img')
+  clearLocalUploadPreview('template_img')
+  formData.product_img = getRecordProductUrl(row) || ''
+  formData.template_img = getRecordReferenceUrl(row) || ''
+}
+
+const regenerateFromHistory = async (row) => {
+  if (!row) return
+  applyHistoryRowToForm(row)
+  recordPreviewVisible.value = false
+  recordDialogVisible.value = false
+  await generateImage()
+}
+
 const optimizePrompt = async () => {
   if (!formData.prompt) { ElMessage.warning('请先输入提示词'); return }
   isOptimizing.value = true
@@ -550,6 +873,7 @@ const optimizePrompt = async () => {
 const taskIdRef = ref('')
 const pollIntervalRef = ref(null)
 const pollCountRef = ref(0)
+let generateSessionId = 0
 
 const fileToBase64 = (file) =>
   new Promise((resolve, reject) => {
@@ -588,18 +912,110 @@ const fileToBase64Compressed = (file, maxSize = 1920, quality = 0.82) =>
     img.src = url
   })
 
+const fetchImageAsBase64 = async (imageUrl) => {
+  if (!imageUrl) return ''
+  const url = formatImageUrl(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: userStore.token ? { 'x-token': userStore.token } : {},
+      credentials: 'include',
+    })
+    if (!res.ok) return ''
+    const blob = await res.blob()
+    const file = new File([blob], 'image.jpg', { type: blob.type || 'image/jpeg' })
+    return await fileToBase64Compressed(file)
+  } catch {
+    return ''
+  }
+}
+
+const stopPolling = () => {
+  if (pollIntervalRef.value) {
+    clearInterval(pollIntervalRef.value)
+    pollIntervalRef.value = null
+  }
+}
+
+const pollGetImageStatus = async (sessionId) => {
+  if (!taskIdRef.value || sessionId !== generateSessionId) {
+    stopPolling()
+    return
+  }
+  try {
+    pollCountRef.value++
+    if (pollCountRef.value > 30) {
+      stopPolling()
+      isGenerating.value = false
+      ElMessage.warning('生成超时,请在「图片生成记录」中查看')
+      return
+    }
+    const statusRes = await GetImageStatus({ task_id: taskIdRef.value })
+    if (sessionId !== generateSessionId) return
+    if (statusRes?.code === 0 && statusRes?.data) {
+      const imageUrl = statusRes.data.image_url || statusRes.data.web_url || statusRes.data.new_image_url
+      if (imageUrl) {
+        if (sessionId !== generateSessionId) return
+        stopPolling()
+        generatedImageUrl.value = imageUrl
+        isGenerating.value = false
+        ElMessage.success('图片生成成功')
+        return
+      }
+      if (statusRes.data.error) {
+        if (sessionId !== generateSessionId) return
+        stopPolling()
+        isGenerating.value = false
+        ElMessage.error('图片生成失败: ' + statusRes.data.error)
+      }
+    }
+  } catch (e) {
+    console.error('轮询查询失败:', e)
+  }
+}
+
+const startPolling = (taskId, sessionId) => {
+  taskIdRef.value = taskId
+  pollCountRef.value = 0
+  stopPolling()
+  pollIntervalRef.value = setInterval(() => pollGetImageStatus(sessionId), 5000)
+  pollGetImageStatus(sessionId)
+}
+
 const generateImage = async () => {
-  if (!formData.prompt?.trim() && !productFileRef.value) {
+  const hasProductSource = !!productFileRef.value || !!formData.product_img
+  if (!formData.prompt?.trim() && !hasProductSource) {
     ElMessage.warning('请填写提示词或上传产品图')
     return
   }
-  const sizeStr = getSizeStr()
-  isGenerating.value = true
-  generatedImageUrl.value = ''
+
+  const sessionId = ++generateSessionId
+  const wasGenerating = isGenerating.value
+  if (!wasGenerating) {
+    generatedImageUrl.value = ''
+    isGenerating.value = true
+  }
+  stopPolling()
+  taskIdRef.value = ''
+  let pollingStarted = false
+
   try {
-    const hasProductImg = !!productFileRef.value
-    const product_img = hasProductImg ? await fileToBase64Compressed(productFileRef.value) : ''
-    const template_img = templateFileRef.value ? await fileToBase64Compressed(templateFileRef.value) : ''
+    const sizeStr = getSizeStr()
+    const hasProductFile = !!productFileRef.value
+    const hasTemplateFile = !!templateFileRef.value
+    let product_img = hasProductFile ? await fileToBase64Compressed(productFileRef.value) : ''
+    let template_img = hasTemplateFile ? await fileToBase64Compressed(templateFileRef.value) : ''
+    if (!product_img && formData.product_img) {
+      product_img = await fetchImageAsBase64(getUploadPreviewUrl('product_img') || formData.product_img)
+    }
+    if (!template_img && formData.template_img) {
+      template_img = await fetchImageAsBase64(getUploadPreviewUrl('template_img') || formData.template_img)
+    }
+    if (sessionId !== generateSessionId) return
+
+    const hasProductImg = hasProductFile || !!product_img
     const params = {
       status_val: hasProductImg ? '图生图' : '文生图',
       status_type: 'ProductImageGeneration',
@@ -608,25 +1024,38 @@ const generateImage = async () => {
       product_img,
       template_img,
       size: sizeStr,
-      sys_id:userStore.userInfo.nickName,
+      sys_id: userStore.userInfo.nickName,
       width: formData.customWidth,
       height: formData.customHeight,
-      sys_id: userStore.userInfo.nickName
     }
-    // console.log(params);return;
-    const res = await CallAIModelApi(params)
-    if (res?.code === 0 && res?.data?.task_id) {
-      taskIdRef.value = res.data.task_id
-      pollCountRef.value = 0
-      ElMessage.success('生成任务已开始,请稍候...')
-      if (pollIntervalRef.value) clearInterval(pollIntervalRef.value)
-      pollIntervalRef.value = setInterval(pollGetImageStatus, 5000)
-      pollGetImageStatus()
+    const res = await CallAIModelApi(params, { donNotShowLoading: true })
+    if (sessionId !== generateSessionId) return
+
+    if (res?.code === 0 && res?.data) {
+      const imageUrl = res.data.image_url || res.data.web_url || res.data.new_image_url
+      const taskId = res.data.task_id
+      if (imageUrl) {
+        generatedImageUrl.value = imageUrl
+        isGenerating.value = false
+        ElMessage.success('图片生成成功')
+      } else if (taskId) {
+        if (wasGenerating) {
+          ElMessage.success('已提交新任务,上一任务可在「图片生成记录」中查看')
+        } else {
+          ElMessage.success('生成任务已开始,请稍候...')
+        }
+        pollingStarted = true
+        startPolling(taskId, sessionId)
+      } else {
+        isGenerating.value = false
+        ElMessage.error(res?.msg || '生成任务启动失败')
+      }
     } else {
-      ElMessage.error(res?.msg || '生成任务启动失败')
       isGenerating.value = false
+      ElMessage.error(res?.msg || '生成任务启动失败')
     }
   } catch (e) {
+    if (sessionId !== generateSessionId) return
     const status = e?.response?.status
     let msg = '生成失败: ' + (e?.message || '未知错误')
     if (status === 431) msg = '431 请求头过大:请检查 nginx 配置 large_client_header_buffers'
@@ -634,54 +1063,11 @@ const generateImage = async () => {
     console.error('[立即生成] 错误:', status, e?.response?.data)
     ElMessage.error(msg)
     isGenerating.value = false
-  }
-}
-
-const pollGetImageStatus = async () => {
-  const stopPolling = () => {
-    if (pollIntervalRef.value) {
-      clearInterval(pollIntervalRef.value)
-      pollIntervalRef.value = null
-    }
-    isGenerating.value = false
-  }
-  try {
-    pollCountRef.value++
-    // 轮询超过 30 次(约 2.5 分钟)停止
-    if (pollCountRef.value > 30) {
-      stopPolling()
-      ElMessage.warning('生成超时,请稍后手动检查结果')
-      return
-    }
-    const statusRes = await GetImageStatus({ task_id: taskIdRef.value })
-    if (statusRes?.code === 0 && statusRes?.data) {
-      if (statusRes.data.image_url) {
-        stopPolling()
-        generatedImageUrl.value = statusRes.data.image_url
-        ElMessage.success(`效果图生成成功!耗时约${pollCountRef.value * 5}秒`)
-      } else if (statusRes.data.error) {
-        stopPolling()
-        ElMessage.error('图片生成失败: ' + statusRes.data.error)
-      }
-      // 无 image_url 且无 error:任务进行中,继续轮询
-    } else {
-      stopPolling()
-      ElMessage.error('查询生成状态失败: ' + (statusRes?.msg || '未知错误'))
+  } finally {
+    if (sessionId === generateSessionId && !pollingStarted && !wasGenerating) {
+      isGenerating.value = false
     }
-  } catch (e) {
-    console.error('轮询查询失败:', e)
-    // 轮询失败继续尝试
-  }
-}
-
-// 取消生成
-const cancelGenerate = () => {
-  if (pollIntervalRef.value) {
-    clearInterval(pollIntervalRef.value)
-    pollIntervalRef.value = null
   }
-  isGenerating.value = false
-  ElMessage.info('已取消生成')
 }
 
 // 获取最终尺寸字符串(给接口用)
@@ -805,13 +1191,38 @@ const downloadImage = async () => {
 .image-uploader.square {
   width: 100%;
   :deep(.el-upload) { width: 100%; display: block; }
+  :deep(.el-upload-dragger) {
+    width: 100%;
+    aspect-ratio: 1;
+    height: auto !important;
+    min-height: 80px;
+    max-height: 130px;
+    max-width: 130px;
+    margin: 0 auto;
+    padding: 0;
+    border: none;
+    border-radius: 6px;
+    background: transparent;
+    overflow: hidden;
+    box-sizing: border-box;
+    &.is-dragover .upload-placeholder,
+    &.is-dragover .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;
+    width: 100%; aspect-ratio: 1; height: 100% !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;
+    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; } }
+  .upload-placeholder {
+    color: #8c939d;
+    span { font-size: 11px; margin-top: 2px; }
+    .upload-drag-tip { font-size: 10px; color: #c0c4cc; margin-top: 4px; }
+  }
   .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; flex-shrink: 0; } }
@@ -936,7 +1347,7 @@ const downloadImage = async () => {
 .result-image { max-width: 100%; max-height: 100%; flex: 1; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
 .result-empty { text-align: center; color: #909399; display: flex; flex-direction: column; align-items: center; gap: 8px; .empty-icon { color: #dcdfe6; margin-bottom: 6px; } p { margin: 0 0 4px; font-size: 13px; } span { font-size: 11px; color: #c0c4cc; } }
 .generate-btn-center { margin-top: 8px; }
-.result-loading { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #606266; .loading-icon { color: #409eff; margin-bottom: 4px; } p { margin: 0; font-size: 14px; } }
+.result-loading { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #606266; .loading-icon { color: #409eff; margin-bottom: 4px; } p { margin: 0; font-size: 14px; } .result-loading-next-btn { margin-top: 4px; } }
 .image-error-large { width: 100%; flex: 1; min-height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f5f7fa; color: #909399; gap: 8px; }
 
 /* 结果卡片头部:仅图片生成记录按钮,靠左 */
@@ -968,93 +1379,67 @@ const downloadImage = async () => {
 }
 .record-btn-header .el-icon { font-size: 16px; }
 
-/* 图片生成记录弹窗 */
-.record-dialog-inner {
-  flex: 1 1 auto;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  max-height: min(62vh, calc(100vh - 200px));
-}
-.record-list {
-  flex: 1 1 auto;
-  min-height: 120px;
-  min-width: 0;
-  overflow-y: auto;
-}
-.record-empty { text-align: center; color: #909399; padding: 40px; font-size: 13px; }
-.record-item { padding: 12px; border-bottom: 1px solid #ebeef5; min-width: 0; &:last-child { border-bottom: 0; } }
-/* 三列始终一行:不换行,等分宽度并随弹窗变窄而缩小 */
-.record-images {
-  display: flex;
-  flex-wrap: nowrap;
-  gap: 8px;
-  margin-bottom: 10px;
-  width: 100%;
-  min-width: 0;
-}
-.record-img-cell {
-  flex: 1 1 0;
-  min-width: 0;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  align-items: stretch;
-}
-.record-img-label { font-size: 11px; color: #909399; flex-shrink: 0; }
-.record-img-frame {
-  position: relative;
-  width: 100%;
-  aspect-ratio: 1;
-  min-height: 0;
-  border-radius: 4px;
-  background: #f5f7fa;
-  overflow: hidden;
-}
-.record-img-fill {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  border-radius: 4px;
-}
-.record-img-fill :deep(.el-image__wrapper) {
-  width: 100%;
-  height: 100%;
-}
-.record-img-fill :deep(.el-image__inner) {
-  object-fit: contain;
-}
-.record-img-placeholder {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  border: 1px dashed #dcdfe6;
-  border-radius: 4px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #c0c4cc;
-  font-size: 11px;
-  text-align: center;
-  padding: 4px;
-  box-sizing: border-box;
-  background: #fafafa;
-}
-.record-img-error { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #f5f7fa; color: #909399; }
-.record-meta { font-size: 12px; color: #606266; }
-.record-prompt { margin-bottom: 6px; line-height: 1.5; .meta-label { color: #909399; margin-right: 4px; } }
-.record-info { display: flex; flex-wrap: wrap; gap: 12px; color: #909399; font-size: 11px; }
-.record-pagination {
-  margin-top: 8px;
-  padding-top: 12px;
-  border-top: 1px solid #ebeef5;
-  display: flex;
-  justify-content: flex-end;
-  flex-wrap: wrap;
-  gap: 8px;
-}
+.history-ref-thumb { width: 48px; height: 48px; border-radius: 4px; cursor: zoom-in; }
+.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; }
+}
+.record-dialog-pagination { 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) minmax(100px, 1fr) minmax(120px, 1fr) 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-side .record-preview-image-panel { flex: 1; min-width: 0; }
+.record-preview-image-panel {
+  position: relative; width: 100%; min-height: min(42vh, 400px); max-height: min(54vh, 460px);
+  border-radius: 10px; border: 1px solid #dcdfe6; background: #f9fafb; overflow: hidden;
+  display: flex; align-items: center; justify-content: center;
+}
+.record-preview-main-image {
+  width: 100%; height: 100%;
+  :deep(.el-image__inner) { max-width: 100%; max-height: min(54vh, 460px); object-fit: contain; }
+}
+.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-side-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #c0c4cc; font-size: 13px; }
+.frame-thumb-image {
+  width: 100%; height: 100%;
+  :deep(.el-image__inner) { width: 100%; height: 100%; object-fit: cover; }
+}
+.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; }
+.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;
+}
+.record-preview-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; width: 100%; }
 
 @media (max-width: 900px) {
   .two-column-layout { flex-direction: column; }
@@ -1068,8 +1453,7 @@ const downloadImage = async () => {
   .upload-cell { flex: 0 0 auto; width: 110px; }
   .upload-cell { max-width: 110px; }
   .image-uploader.square .upload-placeholder, .image-uploader.square .uploaded-preview { min-height: 70px; max-height: 100px; max-width: 100px; }
-  .record-dialog-inner { max-height: min(52vh, calc(100vh - 240px)); }
-  .record-pagination { justify-content: center; }
+  .record-dialog-pagination { justify-content: center; }
 }
 </style>
 <style lang="scss">
@@ -1149,25 +1533,104 @@ const downloadImage = async () => {
 .model-select-popper .el-input__wrapper.is-focus {
   box-shadow: 0 0 0 1px #409eff;
 }
-/* 图片生成记录弹窗:勿写 margin:0 !important,否则会覆盖 align-center 的 margin:0 auto,导致弹窗贴左上 */
-.record-dialog.el-dialog {
-  max-height: calc(100vh - 32px);
-  display: flex;
-  flex-direction: column;
+.el-overlay-dialog:has(.product-record-dialog) {
+  display: flex !important;
+  align-items: center !important;
+  justify-content: center !important;
+}
+
+.product-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;
 }
-.record-dialog .el-dialog__header {
+
+.product-record-dialog .el-dialog__header {
   flex-shrink: 0;
 }
-.record-dialog .el-dialog__body {
-  min-width: 0;
+
+.product-record-dialog .el-dialog__body {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
   box-sizing: border-box;
-  flex: 1 1 auto;
+  padding-top: 8px;
+  padding-bottom: 12px;
+}
+
+.product-record-dialog .product-record-dialog-inner {
+  flex: 1;
   min-height: 0;
   overflow: hidden;
   display: flex;
   flex-direction: column;
+  width: 100%;
 }
-.record-dialog .el-dialog__footer {
+
+.product-record-dialog .product-record-table-wrap {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+  width: 100%;
+}
+
+.product-record-dialog .product-record-table-wrap .el-table {
+  width: 100% !important;
+  height: 100% !important;
+}
+
+.product-record-dialog .product-record-table-wrap .el-table__inner-wrapper {
+  height: 100% !important;
+}
+
+.product-record-dialog .product-record-table-wrap .el-table__body-wrapper {
+  overflow-y: auto !important;
+  overflow-x: auto;
+}
+
+.product-record-dialog .record-dialog-pagination {
   flex-shrink: 0;
 }
+
+.product-record-preview-dialog .el-dialog__header {
+  padding-bottom: 8px;
+  margin-right: 0;
+}
+
+.product-record-preview-dialog .el-dialog__body {
+  padding-top: 4px;
+  padding-bottom: 12px;
+  max-height: calc(88vh - 132px);
+  overflow-y: auto;
+}
+
+.product-record-preview-dialog .el-dialog__footer {
+  padding-top: 12px;
+  border-top: 1px solid #ebeef5;
+}
+
+.el-image-viewer__wrapper {
+  z-index: 10001 !important;
+}
+
+.product-record-prompt-popover {
+  padding: 0 !important;
+}
+
+.product-record-prompt-popover .history-prompt-popover-body {
+  max-height: 180px;
+  overflow-y: auto;
+  padding: 10px 12px;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #606266;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
 </style>

+ 2533 - 0
src/view/Product/ProductVideoGeneration.vue

@@ -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>

+ 1125 - 0
src/view/Product/ProductWhiteBackground.vue

@@ -0,0 +1,1125 @@
+<template>
+  <div class="product-white-background">
+    <div class="two-column-layout">
+      <div class="left-column">
+        <el-card class="config-card" shadow="hover">
+          <div class="config-body">
+            <el-form :model="formData" label-position="top" size="small" class="config-form" hide-required-asterisk>
+              <el-form-item class="upload-row">
+                <div class="upload-row-inner">
+                  <div class="upload-cell">
+                    <span class="upload-label">产品图<span class="required"> *</span></span>
+                    <el-upload
+                      class="image-uploader square"
+                      drag
+                      :action="`${uploadPath}/fileUploadAndDownload/upload`"
+                      :headers="{ 'x-token': userStore.token, 'x-user-id': userStore.userInfo?.ID ?? '' }"
+                      :show-file-list="false"
+                      :on-success="handleUploadSuccess"
+                      :on-error="handleUploadError"
+                      :before-upload="beforeImageUpload"
+                      accept="image/jpeg,image/png,image/webp"
+                    >
+                      <div v-if="formData.product_img || previewBlobUrl" class="uploaded-preview">
+                        <el-image :src="getUploadPreviewUrl()" 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>
+                        <span v-if="fileSizeText" class="upload-size">{{ fileSizeText }}</span>
+                      </div>
+                      <div v-else class="upload-placeholder">
+                        <el-icon :size="20"><Plus /></el-icon>
+                        <span>上传产品图</span>
+                      </div>
+                    </el-upload>
+                  </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="formData.prompt"
+                      type="textarea"
+                      :rows="5"
+                      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="formData.model"
+                    placeholder="请选择模型"
+                    size="small"
+                    class="model-select"
+                    filterable
+                    popper-class="model-select-popper"
+                    placement="bottom-start"
+                    :popper-options="modelSelectPopperOptions"
+                  >
+                    <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-groups">
+                    <div
+                      v-for="group in displaySizeGroups"
+                      :key="group.key"
+                      class="size-group"
+                      :class="{ 'size-group-no-label': group.isPresetRow }"
+                    >
+                      <span v-if="!group.isPresetRow" class="size-group-label">{{ group.label }}</span>
+                      <div class="size-group-options">
+                        <template v-if="group.isImageRefsRow">
+                          <span
+                            class="size-chip"
+                            :class="{ active: formData.size === productSizeValue, disabled: !productImageSize }"
+                            @click="productImageSize && applyProductSize()"
+                          >产品图 {{ productImageSize ? `${productImageSize.width}×${productImageSize.height}` : '-' }}</span>
+                        </template>
+                        <template v-else-if="group.isCustom">
+                          <span class="size-chip" :class="{ active: formData.size === 'custom' }" @click="formData.size = 'custom'">自定义</span>
+                          <span class="size-input-label">宽</span>
+                          <el-input v-model.number="formData.customWidth" size="small" placeholder="宽度" type="number" min="1" class="custom-size-input" @focus="formData.size = 'custom'" />
+                          <span class="size-sep">×</span>
+                          <span class="size-input-label">高</span>
+                          <el-input v-model.number="formData.customHeight" size="small" placeholder="高度" type="number" min="1" class="custom-size-input" @focus="formData.size = 'custom'" />
+                        </template>
+                        <template v-else>
+                          <span
+                            v-for="item in group.items"
+                            :key="item.value"
+                            class="size-chip"
+                            :class="{ active: formData.size === item.value }"
+                            @click="selectSize(item)"
+                          >{{ item.label }}</span>
+                        </template>
+                      </div>
+                    </div>
+                  </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 type="primary" link size="small" class="record-btn-header" @click="openRecordDialog">
+              抠图生成记录
+            </el-button>
+          </template>
+          <div class="result-area">
+            <div v-if="generatedImageUrl" class="result-preview">
+              <el-image
+                :src="formatImageUrl(generatedImageUrl)"
+                fit="contain"
+                class="result-image"
+                :preview-src-list="[formatImageUrl(generatedImageUrl)]"
+                preview-teleported
+              >
+                <template #error>
+                  <div class="image-error-large"><el-icon :size="48"><Picture /></el-icon><span>图片加载失败</span></div>
+                </template>
+              </el-image>
+              <div class="result-actions">
+                <el-button type="primary" size="small" :loading="isGenerating" @click="generateImage">
+                  {{ isGenerating ? '抠图中...' : '再次抠图' }}
+                </el-button>
+                <el-button type="primary" size="small" @click="downloadImage">
+                  <el-icon><Download /></el-icon>下载
+                </el-button>
+              </div>
+            </div>
+            <div v-else-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="generateImage">
+                继续生成下一张
+              </el-button>
+            </div>
+            <div v-else class="result-empty">
+              <el-icon :size="60" class="empty-icon"><Picture /></el-icon>
+              <p>抠图结果将在此处显示</p>
+              <span>上传产品图后点击下方按钮开始抠图</span>
+              <el-button type="primary" :loading="isGenerating" size="large" class="generate-btn-center" @click="generateImage">
+                {{ isGenerating ? '抠图中...' : '立即抠图' }}
+              </el-button>
+            </div>
+          </div>
+        </el-card>
+      </div>
+    </div>
+
+    <el-dialog
+      v-model="recordDialogVisible"
+      title="抠图生成记录"
+      align-center
+      style="width: 90%;height: 90%;"
+      class="white-bg-record-dialog"
+      destroy-on-close
+      @open="onRecordDialogOpen"
+    >
+      <div class="white-bg-record-dialog-inner">
+        <div class="white-bg-record-table-wrap">
+          <el-table v-loading="recordLoading" :data="recordList" stripe size="small" style="width: 100%;height: 100%;">
+            <el-table-column align="center" prop="id" label="ID" width="70" />
+            <el-table-column label="原图" width="80" align="center">
+              <template #default="scope">
+                <el-image
+                  v-if="getRecordProductUrl(scope.row)"
+                  :src="formatImageUrl(getRecordProductUrl(scope.row))"
+                  fit="cover"
+                  class="history-ref-thumb"
+                  :preview-src-list="[formatImageUrl(getRecordProductUrl(scope.row))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <span v-else class="history-ref-empty">-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="抠图结果" width="96" align="center">
+              <template #default="scope">
+                <el-image
+                  v-if="getRecordGeneratedUrl(scope.row)"
+                  :src="formatImageUrl(getRecordGeneratedUrl(scope.row))"
+                  fit="cover"
+                  class="history-ref-thumb"
+                  :preview-src-list="[formatImageUrl(getRecordGeneratedUrl(scope.row))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <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="getRecordPrompt(scope.row)"
+                  placement="top-start"
+                  trigger="hover"
+                  :show-after="200"
+                  :width="300"
+                  teleported
+                  popper-class="white-bg-prompt-popover"
+                >
+                  <template #reference>
+                    <span class="history-prompt-cell">{{ getRecordPrompt(scope.row) }}</span>
+                  </template>
+                  <div class="history-prompt-popover-body">{{ getRecordPrompt(scope.row) }}</div>
+                </el-popover>
+                <span v-else>-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="尺寸" width="100" align="center">
+              <template #default="scope">{{ scope.row.size || '-' }}</template>
+            </el-table-column>
+            <el-table-column prop="createTime" label="生成时间" width="168" />
+            <el-table-column label="状态" width="88" align="center">
+              <template #default="scope">
+                <el-tag size="small" :type="getRecordGeneratedUrl(scope.row) ? 'success' : 'warning'">
+                  {{ getRecordGeneratedUrl(scope.row) ? '已完成' : '生成中' }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="160" fixed="right">
+              <template #default="scope">
+                <el-button size="small" type="primary" link :disabled="!getRecordGeneratedUrl(scope.row)" @click="viewRecordImage(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="recordPage"
+            v-model:page-size="recordPageSize"
+            :page-sizes="[10, 30, 50, 100]"
+            layout="total, sizes, prev, pager, next, jumper"
+            :total="recordTotal"
+            @size-change="handleRecordSizeChange"
+            @current-change="loadRecordList"
+          />
+        </div>
+      </div>
+    </el-dialog>
+
+    <el-dialog
+      v-model="recordPreviewVisible"
+      title="抠图预览"
+      align-center
+      width="min(96vw, 1180px)"
+      class="white-bg-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="getRecordGeneratedUrl(recordPreviewItem)" 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">{{ recordPreviewItem.createTime || '-' }}</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 has-side">
+          <div class="record-preview-image-panel">
+            <el-image
+              v-if="getRecordGeneratedUrl(recordPreviewItem)"
+              :src="formatImageUrl(getRecordGeneratedUrl(recordPreviewItem))"
+              fit="contain"
+              class="record-preview-main-image"
+              :preview-src-list="[formatImageUrl(getRecordGeneratedUrl(recordPreviewItem))]"
+              preview-teleported
+              hide-on-click-modal
+              :z-index="IMAGE_PREVIEW_Z_INDEX"
+            />
+            <div v-else class="media-fallback media-generating">
+              <el-icon :size="32" class="is-loading"><Loading /></el-icon>
+              <span>抠图生成中,请稍等...</span>
+            </div>
+          </div>
+          <aside class="record-preview-frames-side">
+            <div class="frame-side-card">
+              <span class="frame-side-label">原图</span>
+              <div class="frame-side-thumb">
+                <el-image
+                  v-if="getRecordProductUrl(recordPreviewItem)"
+                  :src="formatImageUrl(getRecordProductUrl(recordPreviewItem))"
+                  fit="cover"
+                  class="frame-thumb-image"
+                  :preview-src-list="[formatImageUrl(getRecordProductUrl(recordPreviewItem))]"
+                  preview-teleported
+                  hide-on-click-modal
+                  :z-index="IMAGE_PREVIEW_Z_INDEX"
+                />
+                <div v-else class="frame-side-empty">-</div>
+              </div>
+              <el-button v-if="getRecordProductUrl(recordPreviewItem)" type="primary" link size="small" @click="downloadPreviewImage('product')">下载</el-button>
+            </div>
+          </aside>
+        </div>
+
+        <div class="record-preview-prompt-block">
+          <div class="record-preview-section-title">提示词</div>
+          <div class="record-preview-prompt-scroll">
+            {{ getRecordPrompt(recordPreviewItem) || '-' }}
+          </div>
+        </div>
+      </div>
+      <template #footer>
+        <div class="record-preview-footer">
+          <el-button type="primary" plain size="small" @click="regenerateFromHistory(recordPreviewItem)">重新生成</el-button>
+          <el-button type="primary" size="small" :disabled="!recordPreviewItem || !getRecordGeneratedUrl(recordPreviewItem)" @click="downloadPreviewImage('result')">下载抠图</el-button>
+          <el-button size="small" @click="recordPreviewVisible = false">关闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useUserStore } from '@/pinia/modules/user'
+import { GetAIModel, CallAIModelApi, GetImageStatus, GetProductList } from '@/api/mes/job'
+import { displayImageUrl } from '@/utils/displayImageUrl.js'
+import { Plus, Picture, MagicStick, Download, Loading } from '@element-plus/icons-vue'
+
+defineOptions({ name: 'ProductWhiteBackground' })
+
+const STATUS_TYPE = '抠图'
+const DEFAULT_PROMPT = '将产品主体从背景中完整抠出,生成纯白底产品图,边缘清晰自然,保留产品细节'
+
+const userStore = useUserStore()
+const uploadPath = ref(import.meta.env.VITE_BASE_API)
+const modelSelectPopperOptions = { strategy: 'fixed' }
+const IMAGE_PREVIEW_Z_INDEX = 10001
+const MAX_FILE_SIZE_MB = 5
+
+const formData = reactive({
+  product_img: '',
+  prompt: DEFAULT_PROMPT,
+  size: '1024x1024',
+  customWidth: 1024,
+  customHeight: 1024,
+  model: '',
+})
+
+const sizePresets = [
+  { label: '1:1', value: '1024x1024' },
+  { label: '2:3', value: '768x1152' },
+  { label: '3:2', value: '1152x768' },
+  { label: '3:4', value: '768x1024' },
+  { label: '4:3', value: '1024x768' },
+  { label: '4:5', value: '1024x1280' },
+  { label: '5:4', value: '1280x1024' },
+  { label: '9:16', value: '1080x1920' },
+  { label: '16:9', value: '1920x1080' },
+  { label: '21:9', value: '2560x1080' },
+]
+
+const displaySizeGroups = computed(() => [
+  { key: 'preset', isPresetRow: true, items: sizePresets },
+  { key: 'imageRefs', label: '产品图', isImageRefsRow: true },
+  { key: 'custom', isCustom: true },
+])
+
+const modelList = ref([])
+const productImageSize = ref(null)
+const productFileRef = ref(null)
+const previewBlobUrl = ref('')
+const fileSizeText = ref('')
+const isGenerating = ref(false)
+const isOptimizing = ref(false)
+const generatedImageUrl = ref('')
+
+const formatImageUrl = displayImageUrl
+
+const productSizeValue = computed(() =>
+  productImageSize.value ? `product_${productImageSize.value.width}x${productImageSize.value.height}` : ''
+)
+
+function dedupeModelListByAlias (list) {
+  const seen = new Set()
+  return list.filter((item) => {
+    const raw = (item.model_alias || '').trim()
+    const key = raw ? raw.toLowerCase() : `id:${item.id ?? item.ID ?? ''}`
+    if (seen.has(key)) return false
+    seen.add(key)
+    return true
+  })
+}
+
+const loadModelList = async () => {
+  try {
+    const res = await GetAIModel({ model_type: '图生图' })
+    const list = res?.data?.list ?? res?.data ?? []
+    const raw = Array.isArray(list) ? list : []
+    modelList.value = dedupeModelListByAlias(raw)
+    if (modelList.value.length > 0) {
+      const first = modelList.value[0]
+      formData.model = first.model_name || first.model_alias || ''
+    }
+  } catch {
+    modelList.value = []
+  }
+}
+
+const formatFileSize = (bytes) => {
+  if (!bytes) return ''
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / 1024 / 1024).toFixed(2)} MB`
+}
+
+const getUploadPreviewUrl = () => {
+  if (previewBlobUrl.value) return previewBlobUrl.value
+  const url = formData.product_img
+  if (!url || typeof url !== 'string') return ''
+  const u = url.trim()
+  if (!u || u.startsWith('http')) return u.startsWith('http') ? u : ''
+  return displayImageUrl(u.replace(/^\//, ''))
+}
+
+const beforeImageUpload = (file) => {
+  const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
+  if (!ok) { ElMessage.error('仅支持 jpg、png、webp 格式'); return false }
+  if (file.size / 1024 / 1024 > MAX_FILE_SIZE_MB) {
+    ElMessage.error(`图片大小不能超过 ${MAX_FILE_SIZE_MB}MB`)
+    return false
+  }
+  if (previewBlobUrl.value) URL.revokeObjectURL(previewBlobUrl.value)
+  const blobUrl = URL.createObjectURL(file)
+  previewBlobUrl.value = blobUrl
+  fileSizeText.value = formatFileSize(file.size)
+  productFileRef.value = file
+  return new Promise((resolve) => {
+    const img = new Image()
+    img.onload = () => {
+      productImageSize.value = { width: img.naturalWidth, height: img.naturalHeight }
+      applyProductSize()
+      resolve(true)
+    }
+    img.onerror = () => resolve(true)
+    img.src = blobUrl
+  })
+}
+
+const handleUploadSuccess = (res) => {
+  if (res?.data?.file?.url) {
+    formData.product_img = res.data.file.url
+    ElMessage.success('上传成功')
+  }
+}
+
+const handleUploadError = () => {
+  ElMessage.error('产品图上传失败,请重试或检查网络')
+  if (previewBlobUrl.value) {
+    URL.revokeObjectURL(previewBlobUrl.value)
+    previewBlobUrl.value = ''
+  }
+  productFileRef.value = null
+  formData.product_img = ''
+}
+
+const selectSize = (item) => {
+  formData.size = item.value
+  const [w, h] = item.value.split('x').map(Number)
+  formData.customWidth = w
+  formData.customHeight = h
+}
+
+const applyProductSize = () => {
+  if (!productImageSize.value) return
+  formData.size = productSizeValue.value
+  formData.customWidth = productImageSize.value.width
+  formData.customHeight = productImageSize.value.height
+}
+
+const getSizeStr = () => {
+  if (formData.size === 'custom' || (formData.size && formData.size.startsWith('product_'))) {
+    return `${formData.customWidth || 1024}x${formData.customHeight || 1024}`
+  }
+  return formData.size
+}
+
+const recordDialogVisible = ref(false)
+const recordPreviewVisible = ref(false)
+const recordPreviewItem = ref(null)
+const recordList = ref([])
+const recordLoading = ref(false)
+const recordPage = ref(1)
+const recordPageSize = ref(10)
+const recordTotal = ref(0)
+
+const openRecordDialog = () => { recordDialogVisible.value = true }
+const onRecordDialogOpen = () => { recordPage.value = 1; loadRecordList() }
+
+const loadRecordList = async () => {
+  recordLoading.value = true
+  recordList.value = []
+  try {
+    const res = await GetProductList({
+      sys_id: userStore.userInfo.nickName,
+      status_type: STATUS_TYPE,
+      page: recordPage.value,
+      limit: recordPageSize.value,
+    })
+    const raw = res?.data
+    let list = []
+    if (Array.isArray(raw)) list = raw
+    else if (raw && Array.isArray(raw.list)) list = raw.list
+    recordList.value = list.map(normalizeRecordRow)
+    recordTotal.value = Number(res?.total ?? res?.count ?? raw?.total ?? 0)
+  } catch {
+    recordList.value = []
+    recordTotal.value = 0
+  } finally {
+    recordLoading.value = false
+  }
+}
+
+const handleRecordSizeChange = (size) => {
+  recordPageSize.value = size
+  recordPage.value = 1
+  loadRecordList()
+}
+
+const pickRecordImagePath = (row, ...keys) => {
+  for (const k of keys) {
+    const v = row?.[k]
+    if (v != null && String(v).trim()) return String(v).trim()
+  }
+  return ''
+}
+
+const getRecordProductUrl = (row) => pickRecordImagePath(row, 'product_img', 'old_img', 'product_image')
+const getRecordGeneratedUrl = (row) => pickRecordImagePath(row, 'generated_image', 'new_img', 'image_url', 'web_url')
+const getRecordPrompt = (row) => {
+  const prompt = row?.prompt || ''
+  return prompt ? String(prompt).trim() : ''
+}
+const getPreviewTaskId = (row) => row?.task_id || row?.id || '-'
+
+const normalizeRecordRow = (item) => ({
+  ...item,
+  product_img: getRecordProductUrl(item),
+  generated_image: getRecordGeneratedUrl(item),
+  prompt: getRecordPrompt(item),
+  createTime: item.createTime || item.sys_rq || '',
+})
+
+const viewRecordImage = (row) => {
+  if (!getRecordGeneratedUrl(row)) {
+    ElMessage.warning('图片尚未生成完成')
+    return
+  }
+  recordPreviewItem.value = row
+  recordPreviewVisible.value = true
+}
+
+const downloadPreviewImageAsset = async (rawUrl, filename) => {
+  if (!rawUrl) return
+  const fullUrl = formatImageUrl(rawUrl)
+  const url = fullUrl.startsWith('http') ? fullUrl : (fullUrl.startsWith('/') ? fullUrl : `/${fullUrl}`)
+  try {
+    ElMessage.info('下载中...')
+    const res = await fetch(url, {
+      headers: userStore.token ? { 'x-token': userStore.token } : {},
+      credentials: 'include',
+    })
+    if (!res.ok) throw new Error(res.statusText)
+    const blob = await res.blob()
+    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)
+    }, 100)
+    ElMessage.success('下载完成')
+  } catch (error) {
+    console.error('[下载] 失败:', error)
+    ElMessage.error('下载失败,请重试')
+  }
+}
+
+const downloadPreviewImage = (which) => {
+  const row = recordPreviewItem.value
+  if (!row) return
+  const id = getPreviewTaskId(row)
+  const raw = which === 'product' ? getRecordProductUrl(row) : getRecordGeneratedUrl(row)
+  if (!raw) return
+  const name = which === 'product' ? '原图' : '抠图'
+  downloadPreviewImageAsset(raw, `${name}_${id}.png`)
+}
+
+const clearLocalUploadPreview = () => {
+  if (previewBlobUrl.value) {
+    URL.revokeObjectURL(previewBlobUrl.value)
+    previewBlobUrl.value = ''
+  }
+  fileSizeText.value = ''
+  productFileRef.value = null
+  productImageSize.value = null
+}
+
+const applyHistoryRowToForm = (row) => {
+  if (!row) return
+  formData.prompt = getRecordPrompt(row) || DEFAULT_PROMPT
+  if (row.model) formData.model = row.model
+  if (row.size) {
+    const sizeStr = String(row.size).replace('×', 'x')
+    const preset = sizePresets.find((p) => p.value === sizeStr)
+    if (preset) selectSize(preset)
+    else if (sizeStr.includes('x')) {
+      const [w, h] = sizeStr.split('x').map(Number)
+      if (w && h) {
+        formData.customWidth = w
+        formData.customHeight = h
+        formData.size = sizeStr
+      }
+    }
+  }
+  clearLocalUploadPreview()
+  formData.product_img = getRecordProductUrl(row) || ''
+}
+
+const regenerateFromHistory = async (row) => {
+  if (!row) return
+  applyHistoryRowToForm(row)
+  recordPreviewVisible.value = false
+  recordDialogVisible.value = false
+  await generateImage()
+}
+
+const optimizePrompt = async () => {
+  if (!formData.prompt?.trim()) { ElMessage.warning('请先输入提示词'); return }
+  isOptimizing.value = true
+  try {
+    const res = await CallAIModelApi({
+      status_val: '文生文',
+      status_type: STATUS_TYPE,
+      prompt: formData.prompt,
+      model: 'gpt-4',
+      sys_id: userStore.userInfo.nickName,
+    }, { donNotShowLoading: true })
+    const content = res?.data?.content ?? res?.content
+    if (res?.code === 0 && content) {
+      formData.prompt = content
+      ElMessage.success('提示词优化完成')
+    } else {
+      ElMessage.error(res?.msg || '优化失败')
+    }
+  } catch (e) {
+    console.error('[优化提示词] 错误:', e)
+    ElMessage.error('优化失败: ' + (e?.message || '未知错误'))
+  } finally {
+    isOptimizing.value = false
+  }
+}
+
+const taskIdRef = ref('')
+const pollIntervalRef = ref(null)
+const pollCountRef = ref(0)
+let generateSessionId = 0
+
+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
+      canvas.getContext('2d').drawImage(img, 0, 0, w, h)
+      resolve(canvas.toDataURL('image/jpeg', quality))
+    }
+    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
+    img.src = url
+  })
+
+const fetchImageAsBase64 = async (imageUrl) => {
+  if (!imageUrl) return ''
+  const url = formatImageUrl(imageUrl)
+  if (!url || url.startsWith('data:')) return url || ''
+  try {
+    const fetchUrl = /^https?:\/\//i.test(url) ? url : (url.startsWith('/') ? url : `/${url}`)
+    const res = await fetch(fetchUrl, {
+      headers: userStore.token ? { 'x-token': userStore.token } : {},
+      credentials: 'include',
+    })
+    if (!res.ok) return ''
+    const blob = await res.blob()
+    const file = new File([blob], 'image.jpg', { type: blob.type || 'image/jpeg' })
+    return await fileToBase64Compressed(file)
+  } catch {
+    return ''
+  }
+}
+
+const stopPolling = () => {
+  if (pollIntervalRef.value) {
+    clearInterval(pollIntervalRef.value)
+    pollIntervalRef.value = null
+  }
+}
+
+const pollGetImageStatus = async (sessionId) => {
+  if (!taskIdRef.value || sessionId !== generateSessionId) {
+    stopPolling()
+    return
+  }
+  try {
+    pollCountRef.value++
+    if (pollCountRef.value > 30) {
+      stopPolling()
+      isGenerating.value = false
+      ElMessage.warning('生成超时,请在「抠图生成记录」中查看')
+      return
+    }
+    const statusRes = await GetImageStatus({ task_id: taskIdRef.value })
+    if (sessionId !== generateSessionId) return
+    if (statusRes?.code === 0 && statusRes?.data) {
+      const imageUrl = statusRes.data.image_url || statusRes.data.web_url || statusRes.data.new_image_url
+      if (imageUrl) {
+        if (sessionId !== generateSessionId) return
+        stopPolling()
+        generatedImageUrl.value = imageUrl
+        isGenerating.value = false
+        ElMessage.success('抠图生成成功')
+        return
+      }
+      if (statusRes.data.error) {
+        if (sessionId !== generateSessionId) return
+        stopPolling()
+        isGenerating.value = false
+        ElMessage.error('抠图失败: ' + statusRes.data.error)
+      }
+    }
+  } catch (e) {
+    console.error('轮询查询失败:', e)
+  }
+}
+
+const startPolling = (taskId, sessionId) => {
+  taskIdRef.value = taskId
+  pollCountRef.value = 0
+  stopPolling()
+  pollIntervalRef.value = setInterval(() => pollGetImageStatus(sessionId), 5000)
+  pollGetImageStatus(sessionId)
+}
+
+const generateImage = async () => {
+  const hasProductSource = !!productFileRef.value || !!formData.product_img
+  if (!hasProductSource) {
+    ElMessage.warning('请先上传产品图')
+    return
+  }
+  if (!formData.model) {
+    ElMessage.warning('请选择模型')
+    return
+  }
+
+  const sessionId = ++generateSessionId
+  const wasGenerating = isGenerating.value
+  if (!wasGenerating) {
+    generatedImageUrl.value = ''
+    isGenerating.value = true
+  }
+  stopPolling()
+  taskIdRef.value = ''
+  let pollingStarted = false
+
+  try {
+    const sizeStr = getSizeStr()
+    let product_img = productFileRef.value ? await fileToBase64Compressed(productFileRef.value) : ''
+    if (!product_img && formData.product_img) {
+      product_img = await fetchImageAsBase64(getUploadPreviewUrl() || formData.product_img)
+    }
+    if (sessionId !== generateSessionId) return
+
+    const params = {
+      status_val: '图生图',
+      status_type: STATUS_TYPE,
+      prompt: formData.prompt?.trim() || DEFAULT_PROMPT,
+      model: formData.model,
+      product_img,
+      template_img: '',
+      size: sizeStr,
+      sys_id: userStore.userInfo.nickName,
+      width: formData.customWidth,
+      height: formData.customHeight,
+    }
+    const res = await CallAIModelApi(params, { donNotShowLoading: true })
+    if (sessionId !== generateSessionId) return
+
+    if (res?.code === 0 && res?.data) {
+      const imageUrl = res.data.image_url || res.data.web_url || res.data.new_image_url
+      const taskId = res.data.task_id
+      if (imageUrl) {
+        generatedImageUrl.value = imageUrl
+        isGenerating.value = false
+        ElMessage.success('抠图生成成功')
+      } else if (taskId) {
+        if (wasGenerating) {
+          ElMessage.success('已提交新任务,上一任务可在「抠图生成记录」中查看')
+        } else {
+          ElMessage.success('抠图任务已开始,请稍候...')
+        }
+        pollingStarted = true
+        startPolling(taskId, sessionId)
+      } else {
+        isGenerating.value = false
+        ElMessage.error(res?.msg || '抠图任务启动失败')
+      }
+    } else {
+      isGenerating.value = false
+      ElMessage.error(res?.msg || '抠图任务启动失败')
+    }
+  } catch (e) {
+    if (sessionId !== generateSessionId) return
+    const status = e?.response?.status
+    let msg = '抠图失败: ' + (e?.message || '未知错误')
+    if (status === 431) msg = '431 请求头过大:请检查 nginx 配置'
+    if (status === 413) msg = '413 请求体过大:请减小图片尺寸'
+    ElMessage.error(msg)
+    isGenerating.value = false
+  } finally {
+    if (sessionId === generateSessionId && !pollingStarted && !wasGenerating) {
+      isGenerating.value = false
+    }
+  }
+}
+
+const downloadImage = async () => {
+  if (!generatedImageUrl.value) return
+  await downloadPreviewImageAsset(generatedImageUrl.value, `抠图_${Date.now()}.png`)
+}
+
+onMounted(() => { loadModelList() })
+</script>
+
+<style lang="scss" scoped>
+.product-white-background {
+  padding: 6px 10px;
+  height: calc(100vh - 200px);
+  max-height: calc(100vh - 200px);
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.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__header) { padding: 5px 10px; flex-shrink: 0; }
+  :deep(.el-card__body) { padding: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
+}
+.result-card :deep(.el-card__body) { padding: 8px; }
+.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-label { font-size: 12px; color: #606266; .required { color: #f56c6c; } }
+.image-uploader.square {
+  width: 100%;
+  :deep(.el-upload) { width: 100%; display: block; }
+  :deep(.el-upload-dragger) {
+    width: 100%; aspect-ratio: 1; height: auto !important; min-height: 80px; max-height: 130px; max-width: 130px; margin: 0 auto;
+    padding: 0; border: none; border-radius: 6px; background: transparent; overflow: hidden; box-sizing: border-box;
+    &.is-dragover .upload-placeholder, &.is-dragover .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: 100% !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; flex-shrink: 0; }
+    }
+    &:hover .upload-mask { opacity: 1; }
+    .upload-size { font-size: 11px; color: #909399; margin-top: 2px; text-align: center; }
+  }
+}
+.image-error { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #f5f7fa; color: #909399; }
+
+.config-row-compact { margin-bottom: 0 !important; }
+.config-item { display: flex; flex-direction: column; gap: 4px; }
+.config-item.size-item { flex: 1; min-width: 280px; }
+.config-label { font-size: 12px; color: #606266; }
+.model-item { flex: 1; min-width: 260px; max-width: 100%; }
+.model-select {
+  width: 100%; min-width: 100%;
+  :deep(.el-select__wrapper) {
+    border-radius: 8px; border: 1px solid #e4e7ed; background: #fafbfc; box-shadow: none;
+    transition: all 0.2s ease; 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;
+  }
+  :deep(.el-input__inner) { font-size: 13px; color: #303133; }
+  :deep(.el-select__placeholder) { color: #909399; }
+}
+.size-groups { display: flex; flex-direction: column; gap: 6px; }
+.size-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.size-group-no-label .size-group-options { flex: 1; }
+.size-group-label { font-size: 11px; color: #909399; min-width: 32px; flex-shrink: 0; }
+.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; }
+  &.disabled { opacity: 0.6; cursor: not-allowed; pointer-events: none; }
+}
+.size-group .size-input-label { font-size: 12px; color: #606266; margin-right: 4px; white-space: nowrap; }
+.size-group .custom-size-input { width: 100px; }
+.size-sep { color: #909399; font-size: 12px; margin: 0 4px; }
+
+.prompt-form-item {
+  margin-bottom: 4px; width: 100%;
+  :deep(.el-form-item__label) { width: 100%; display: flex; justify-content: flex-start; text-align: left; padding-left: 0; }
+}
+.prompt-wrapper {
+  display: flex; flex-direction: column; width: 100%; border: 1px solid #dcdfe6; border-radius: 6px; overflow: hidden; background: #fff;
+  :deep(.el-textarea) { position: relative; }
+  :deep(.el-textarea__inner) { border: none; border-radius: 0; padding-bottom: 28px; box-shadow: none; }
+  :deep(.el-input__count) { background: #fff; padding-left: 6px; font-size: 12px; color: #909399; }
+}
+.prompt-textarea { flex: 1; min-height: 100px; width: 100%; :deep(textarea) { resize: none; width: 100%; } }
+.prompt-block { display: flex; flex-direction: column; gap: 8px; width: 100%; }
+.prompt-actions-row { width: 100%; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 8px 0; background: transparent; }
+
+.result-card :deep(.el-card__header) {
+  padding: 10px 14px;
+  background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
+  border-bottom: 1px solid #ebeef5;
+}
+.record-btn-header {
+  display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; font-size: 13px; font-weight: 500;
+  border-radius: 8px; color: #409eff; background: #ecf5ff; border: 1px solid #d9ecff;
+  &:hover { background: #409eff; color: #fff; border-color: #409eff; }
+}
+.result-area {
+  flex: 1; min-height: 420px; display: flex; align-items: center; justify-content: center;
+  background: #f9fafb; border-radius: 6px; padding: 12px; position: relative; overflow: hidden;
+}
+.result-preview { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; }
+.result-image { max-width: 100%; max-height: 100%; flex: 1; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
+.result-actions { display: flex; gap: 8px; }
+.result-empty, .result-loading {
+  text-align: center; color: #909399; display: flex; flex-direction: column; align-items: center; gap: 8px;
+  .empty-icon { color: #dcdfe6; } p { margin: 0; font-size: 13px; color: #606266; } span { font-size: 11px; color: #c0c4cc; }
+}
+.result-loading .loading-icon { color: #409eff; }
+.result-loading-next-btn { margin-top: 4px; }
+.generate-btn-center { margin-top: 8px; }
+.image-error-large { width: 100%; flex: 1; min-height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f5f7fa; color: #909399; gap: 8px; }
+
+.history-ref-thumb { width: 48px; height: 48px; border-radius: 4px; cursor: zoom-in; }
+.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; } }
+.record-dialog-pagination { 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) minmax(100px, 1fr) minmax(150px, 1fr);
+  gap: 10px 20px; padding: 12px 14px; background: #f5f7fa; border-radius: 8px; border: 1px solid #ebeef5;
+}
+.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-label { font-size: 12px; color: #909399; }
+.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; }
+.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-side .record-preview-image-panel { flex: 1; min-width: 0; }
+.record-preview-image-panel {
+  position: relative; width: 100%; min-height: min(42vh, 400px); max-height: min(54vh, 460px);
+  border-radius: 10px; border: 1px solid #dcdfe6; background: #f9fafb; overflow: hidden;
+  display: flex; align-items: center; justify-content: center;
+}
+.record-preview-main-image { width: 100%; height: 100%; :deep(.el-image__inner) { max-width: 100%; max-height: min(54vh, 460px); object-fit: contain; } }
+.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; }
+.frame-side-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #c0c4cc; }
+.frame-thumb-image { width: 100%; height: 100%; :deep(.el-image__inner) { width: 100%; height: 100%; object-fit: cover; } }
+.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; }
+.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;
+}
+.record-preview-footer { display: flex; justify-content: flex-end; gap: 8px; width: 100%; }
+
+@media (max-width: 900px) {
+  .two-column-layout { flex-direction: column; }
+  .left-column { flex: 1; min-width: 100%; min-height: 0; }
+  .right-column { flex: 1; min-height: 0; }
+}
+@media (max-width: 600px) {
+  .product-white-background { padding: 4px 8px; }
+  .left-column { max-height: 55vh; }
+  .upload-row-inner { flex-direction: column; align-items: flex-start; }
+  .upload-cell { flex: 0 0 auto; width: 110px; max-width: 110px; }
+  .image-uploader.square .upload-placeholder,
+  .image-uploader.square .uploaded-preview { min-height: 70px; max-height: 100px; max-width: 100px; }
+  .record-dialog-pagination { justify-content: center; }
+}
+</style>
+
+<style lang="scss">
+.product-white-background .prompt-form-item.is-required .el-form-item__label::before {
+  display: none !important;
+  content: none !important;
+}
+</style>
+
+<style lang="scss">
+.el-overlay-dialog:has(.white-bg-record-dialog) {
+  display: flex !important; align-items: center !important; justify-content: center !important;
+}
+.white-bg-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;
+}
+.white-bg-record-dialog .el-dialog__header { flex-shrink: 0; }
+.white-bg-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;
+}
+.white-bg-record-dialog .white-bg-record-dialog-inner { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; width: 100%; }
+.white-bg-record-dialog .white-bg-record-table-wrap { flex: 1; min-height: 0; overflow: hidden; width: 100%; }
+.white-bg-record-dialog .white-bg-record-table-wrap .el-table { width: 100% !important; height: 100% !important; }
+.white-bg-record-dialog .white-bg-record-table-wrap .el-table__inner-wrapper { height: 100% !important; }
+.white-bg-record-dialog .white-bg-record-table-wrap .el-table__body-wrapper { overflow-y: auto !important; overflow-x: auto; }
+.white-bg-record-dialog .record-dialog-pagination { flex-shrink: 0; }
+.white-bg-record-preview-dialog .el-dialog__body { max-height: calc(88vh - 132px); overflow-y: auto; }
+.white-bg-record-preview-dialog .el-dialog__footer { padding-top: 12px; border-top: 1px solid #ebeef5; }
+.el-image-viewer__wrapper { z-index: 10001 !important; }
+.white-bg-prompt-popover { padding: 0 !important; }
+.white-bg-prompt-popover .history-prompt-popover-body {
+  max-height: 180px; overflow-y: auto; padding: 10px 12px; font-size: 13px; line-height: 1.6; color: #606266; white-space: pre-wrap; word-break: break-word;
+}
+</style>

File diff suppressed because it is too large
+ 620 - 112
src/view/performance/QualityAssessment/texttoimage.vue


File diff suppressed because it is too large
+ 672 - 86
src/view/performance/QualityAssessment/video.vue


Some files were not shown because too many files changed in this diff