ソースを参照

feat(generate): 添加湛江场景图生成功能

- 新增 generatePoster 接口用于湛江场景图生成
- 在控制器中添加 generatePoster 方法处理请求
- 更新前端菜单添加湛江场景图选项
- 添加海报生成页面路由配置
- 扩展 IPC 通信接口支持海报生成功能
- 修改摄影功能混入以支持新菜单项
- 在工具控制器中添加窗口可关闭设置功能
- 实现远程图片保存功能支持海报导出
- 添加商品货号搜索组件支持海报配置
- 创建海报相关 API 接口和服务方法
panqiuyao 4 週間 前
親
コミット
e5de118ccf

+ 10 - 0
electron/api/generate.js

@@ -40,4 +40,14 @@ module.exports = {
       data: data,
     })
   },
+
+  generatePoster(data){
+    return post({
+      url: '/shoes_compose?type=0',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
 }

+ 17 - 1
electron/controller/generate.js

@@ -5,7 +5,7 @@ const Log = require('ee-core/log');
 const Services = require('ee-core/services');
 const path = require('path');
 const fs = require('fs');
-const { generatePhotoDetail, getLogoList, addLogo, deleteLogo, getGoodsImageJson } = require('../api/generate');
+const { generatePhotoDetail, getLogoList, addLogo, deleteLogo, getGoodsImageJson, generatePoster } = require('../api/generate');
 
 const errData = {
   msg :'请求失败,请联系管理员',
@@ -91,6 +91,22 @@ class GenerateController extends Controller {
     }
   }
 
+  /**
+   * 湛江场景图生成:本机校验并创建云端任务
+   */
+  async generatePoster(args) {
+    try {
+      Log.info('湛江场景图生成请求', args);
+      const result = await generatePoster(args);
+      Log.info('湛江场景图生成响应', result && result.data ? result.data : result);
+      if (result.data) return result.data;
+      return errData;
+    } catch (error) {
+      Log.error('湛江场景图生成失败:', error);
+      return errData;
+    }
+  }
+
 
 }
 

+ 108 - 0
electron/controller/utils.js

@@ -195,6 +195,20 @@ class UtilsController extends Controller {
 
   }
 
+  setWindowClosable(payload = {}) {
+    const id = payload.id
+    const closable = payload.closable !== false
+    const win = id && this.app.electron ? this.app.electron[id] : null
+    if (!win || win.isDestroyed()) return { code: 1, msg: '窗口不存在' }
+    try {
+      win.setClosable(closable)
+      return { code: 0 }
+    } catch (error) {
+      Log.error('设置窗口关闭状态失败:', error)
+      return { code: 1, msg: error.message }
+    }
+  }
+
   async openDirectory(optiops={
     title:"选择文件夹"
     }){
@@ -359,6 +373,100 @@ class UtilsController extends Controller {
       };
     }
   }
+
+  async saveRemoteImage(payload = {}) {
+    try {
+      const url = payload.url
+      if (!url) return { code: 1, msg: '没有可保存的图片' }
+      const defaultName = payload.defaultName || `湛江场景图_${Date.now()}.png`
+      const dest = dialog.showSaveDialogSync({
+        title: '保存图片到本地',
+        defaultPath: defaultName,
+        filters: [
+          { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp'] },
+        ],
+      })
+      if (!dest) return { code: 1, msg: '已取消保存' }
+      await this._writeImageToFile(url, dest)
+      return { code: 0, msg: '保存成功', path: dest }
+    } catch (error) {
+      Log.error('保存图片失败:', error)
+      return { code: 1, msg: error.message || '保存图片失败' }
+    }
+  }
+
+  async saveRemoteImages(payload = {}) {
+    try {
+      const items = payload.items || []
+      if (!items.length) return { code: 1, msg: '没有可保存的图片' }
+      const dir = dialog.showOpenDialogSync({
+        title: '选择保存文件夹',
+        properties: ['openDirectory', 'createDirectory'],
+      })
+      if (!dir || !dir[0]) return { code: 1, msg: '已取消保存' }
+      const folder = dir[0]
+      let count = 0
+      for (let i = 0; i < items.length; i++) {
+        const item = items[i]
+        if (!item || !item.url) continue
+        let ext = path.extname(String(item.url || '').split('?')[0] || '')
+        if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext.toLowerCase())) ext = '.png'
+        const dest = path.join(folder, this._safeFileName(item.name || `湛江场景图_${i + 1}`) + ext)
+        await this._writeImageToFile(item.url, dest)
+        count += 1
+      }
+      return { code: 0, msg: '保存成功', path: folder, count }
+    } catch (error) {
+      Log.error('批量保存图片失败:', error)
+      return { code: 1, msg: error.message || '保存图片失败' }
+    }
+  }
+
+  _safeFileName(name) {
+    return String(name || '湛江场景图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80)
+  }
+
+  _writeImageToFile(url, dest) {
+    const fsPromises = fs.promises
+    if (typeof url === 'string' && url.startsWith('data:image')) {
+      const match = url.match(/^data:image\/\w+;base64,(.+)$/)
+      const base64Data = match ? match[1] : url
+      return fsPromises.writeFile(dest, Buffer.from(base64Data, 'base64'))
+    }
+    if (typeof url === 'string' && !/^https?:\/\//i.test(url)) {
+      const localPath = url.replace(/^file:\/\//i, '')
+      return fsPromises.copyFile(localPath, dest)
+    }
+    const http = require('http')
+    const https = require('https')
+    return new Promise((resolve, reject) => {
+      const fetchOnce = (target, hops = 0) => {
+        if (hops > 5) {
+          reject(new Error('下载跳转次数过多'))
+          return
+        }
+        const client = target.startsWith('https') ? https : http
+        const req = client.get(target, (res) => {
+          if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+            fetchOnce(res.headers.location, hops + 1)
+            return
+          }
+          if (res.statusCode !== 200) {
+            reject(new Error('下载失败: ' + res.statusCode))
+            return
+          }
+          const chunks = []
+          res.on('data', (chunk) => chunks.push(chunk))
+          res.on('end', () => {
+            fs.writeFile(dest, Buffer.concat(chunks), (err) => err ? reject(err) : resolve())
+          })
+        })
+        req.on('error', reject)
+      }
+      fetchOnce(url)
+    })
+  }
+
   async openFile(optiops= {
     title:"选择文件",
     filters:[

+ 108 - 0
frontend/src/apis/poster.ts

@@ -0,0 +1,108 @@
+import { GET, POST } from '@/utils/http'
+
+export interface PosterTemplate {
+  id: string
+  name: string
+  preview_url: string
+  image_url: string
+}
+
+export interface PosterInfoRow {
+  key: 'number_mid' | 'size_mid' | 'number_big' | 'size_big' | 'box'
+  label: string
+  value: string
+  placeholder?: string
+}
+
+export function defaultPosterInfoRows(): PosterInfoRow[] {
+  return [
+    { key: 'number_mid', label: '货号(中童)', value: '', placeholder: '如 1025B-17' },
+    { key: 'size_mid', label: '尺码 Size', value: '', placeholder: '如 24-29#' },
+    { key: 'number_big', label: '货号(大童)', value: '', placeholder: '如 1025A-17' },
+    { key: 'size_big', label: '尺码 Size', value: '', placeholder: '如 30-35#' },
+    { key: 'box', label: '装箱 Box', value: '', placeholder: '如 60双' },
+  ]
+}
+
+export async function fetchPosterTemplates(keywords?: string): Promise<PosterTemplate[]> {
+  const params: Record<string, any> = {
+    site: 1,
+    current: 1,
+    size: 40,
+    has_count: 1,
+    status: 2,
+    type: 24,
+  }
+  const keyword = (keywords || '').trim()
+  if (keyword) params.keywords = keyword
+  const res: any = await GET('/api/ai_image/main/image_material_list', params, { loading: true })
+  const list = res?.data?.list || []
+  return list.map((item: any) => ({
+    id: String(item.id),
+    name: item.material_name || `样式 ${item.id}`,
+    preview_url: item.pre_image_url || item.image_url || '',
+    image_url: item.image_url || item.pre_image_url || '',
+  })).filter((item: PosterTemplate) => item.id && item.image_url)
+}
+
+export function pickGenerateIds(data: any): { ids: Array<string | number>; channel: string } {
+  if (!data) return { ids: [], channel: '' }
+  const channel = data.generate_channel || data.category || data.type || ''
+  if (Array.isArray(data.generate_ids) && data.generate_ids.length) {
+    return { ids: data.generate_ids.filter((id: any) => id != null && id !== ''), channel }
+  }
+  if (Array.isArray(data.ids) && data.ids.length) {
+    return { ids: data.ids.filter((id: any) => id != null && id !== ''), channel }
+  }
+  if (data.generate_id != null && data.generate_id !== '') {
+    return { ids: [data.generate_id], channel }
+  }
+  if (data.id != null && data.id !== '' && !Array.isArray(data.list)) {
+    return { ids: [data.id], channel }
+  }
+  if (Array.isArray(data)) {
+    const ids = data.map((item: any) => item?.generate_id || item?.id).filter((id: any) => id != null && id !== '')
+    return { ids, channel }
+  }
+  if (Array.isArray(data.list)) {
+    const ids = data.list.map((item: any) => item?.generate_id || item?.id).filter((id: any) => id != null && id !== '')
+    return { ids, channel }
+  }
+  return { ids: [], channel }
+}
+
+export function searchPosterProgress(params: { generate_ids: Array<string | number>; type?: string }) {
+  return POST('/api/ai_image/main/search_bacth_progress', {
+    site: 1,
+    generate_ids: params.generate_ids,
+    type: params.type || 'poster',
+  }, {
+    loading: false,
+    showErrorMessage: false,
+  })
+}
+
+export function fetchPosterHistory(params: { current?: number; size?: number } = {}, loading = false) {
+  return GET('/api/ai_image/main/aigc_user_generates', {
+    current: params.current || 1,
+    size: params.size || 20,
+    category: 79,
+    order: 'desc',
+  }, { loading })
+}
+
+export function normalizePosterHistory(res: any): { list: any[]; total: number } {
+  const data = res?.data
+  if (Array.isArray(data?.list)) {
+    return { list: data.list, total: Number(data.page?.total || data.total || data.list.length) }
+  }
+  if (Array.isArray(data) && data[0] && Array.isArray(data[0].data)) {
+    const list = data.flatMap((day: any) => (day.data || []).map((item: any) => ({
+      ...item,
+      day: day.day,
+    })))
+    return { list, total: list.length }
+  }
+  if (Array.isArray(data)) return { list: data, total: data.length }
+  return { list: [], total: 0 }
+}

+ 8 - 0
frontend/src/router/index.ts

@@ -81,6 +81,14 @@ const routes: RouteRecordRaw[] = [
         }
     },
     {
+        path: "/photography/poster",
+        name: "PhotographyPoster",
+        component: () => import("@/views/Photography/poster/index.vue"),
+        meta: {
+            title: '湛江场景图'
+        }
+    },
+    {
         path: "/remote_control",
         name: "RemoteControl",
         component: () => import("@/views/RemoteControl/index.vue"),

+ 4 - 0
frontend/src/utils/ipc.ts

@@ -31,7 +31,10 @@ const icpList = {
         openFile:"controller.utils.openFile",
         runExternalTool:"controller.utils.runExternalTool",
         saveGeneratedImages:"controller.utils.saveGeneratedImages",
+        saveRemoteImage:"controller.utils.saveRemoteImage",
+        saveRemoteImages:"controller.utils.saveRemoteImages",
         closeAllWindows: 'controller.utils.closeAllWindows',
+        setWindowClosable: 'controller.utils.setWindowClosable',
         downloadFont: 'controller.utils.downloadFont',
         loadFontFromCache: 'controller.utils.loadFontFromCache',
         getFontCachePath: 'controller.utils.getFontCachePath',
@@ -66,6 +69,7 @@ const icpList = {
         getLogoList: 'controller.generate.getLogoList',
         deleteLogo: 'controller.generate.deleteLogo',
         getGoodsImageJson: 'controller.generate.getGoodsImageJson',
+        generatePoster: 'controller.generate.generatePoster',
     },
     ota:{
         updateVersion: 'controller.ota.updateVersion'

+ 21 - 1
frontend/src/utils/menus/generate.ts

@@ -28,7 +28,14 @@ const generate =  {
                 click(configs){
                     openGaenrate('video',configs)
                 },
-            },{
+            },
+            {
+                name:'湛江场景图',
+                click(){
+                    openPosterGenerate()
+                },
+            },
+            {
                 name:'历史记录',
                 click(configs){
                     openGaenrate('mine',configs)
@@ -79,6 +86,19 @@ export function openGaenrate(type,configs) {
     clientStore.ipc.send(icpList.utils.openMain, params)
 }
 
+export function openPosterGenerate() {
+    clientStore.ipc.removeAllListeners(icpList.utils.openMain)
+    const params = {
+        title: '湛江场景图',
+        width: 1400,
+        height: 900,
+        frame: true,
+        id: 'posterGenerate',
+        url: getRouterUrl('#/photography/poster')
+    }
+    clientStore.ipc.send(icpList.utils.openMain, params)
+}
+
 
 
 export default generate;

+ 1 - 2
frontend/src/views/Photography/mixin/usePhotography.ts

@@ -590,8 +590,7 @@ export default function usePhotography() {
 
     /*高级生成*/
     const onGenerateCLick = (menu, item) => {
-      if (menu.name === '历史记录') {
-
+      if (menu.name === '历史记录' || menu.name === '湛江场景图') {
         menu.click()
         return
       }

+ 148 - 0
frontend/src/views/Photography/poster/GoodsArtNoSearch.vue

@@ -0,0 +1,148 @@
+<template>
+  <el-select
+    :model-value="modelValue"
+    filterable
+    remote
+    clearable
+    :remote-method="onRemote"
+    :loading="searching"
+    :placeholder="placeholder"
+    style="width: 100%"
+    @focus="onFocus"
+    @change="onChange"
+  >
+    <el-option
+      v-for="item in options"
+      :key="item.goods_art_no"
+      :label="item.goods_art_no"
+      :value="item.goods_art_no"
+    >
+      <div class="goods-option">
+        <span class="goods-option__no">{{ item.goods_art_no }}</span>
+        <span class="goods-option__meta">{{ item.items?.length || 0 }}张 · {{ item.action_time || '' }}</span>
+      </div>
+    </el-option>
+  </el-select>
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, ref } from 'vue'
+import client from '@/stores/modules/client'
+import icpList from '@/utils/ipc'
+
+interface GoodsItem {
+  goods_art_no: string
+  action_time?: string
+  items?: any[]
+  syncConfig?: boolean
+}
+
+const props = defineProps({
+  modelValue: { type: String, default: '' },
+  placeholder: { type: String, default: '搜索货号' },
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: string): void
+  (e: 'select', item: GoodsItem | null): void
+}>()
+
+const clientStore = client()
+const options = ref<GoodsItem[]>([])
+const searching = ref(false)
+let timer: ReturnType<typeof setTimeout> | null = null
+let seq = 0
+
+function normalizeList(list: any[]): GoodsItem[] {
+  return (list || []).map((item) => {
+    const hasNullActionName = item.items && Array.isArray(item.items)
+      && item.items.some((image: any) => image.action_name === null || image.action_name === undefined)
+    return {
+      ...item,
+      syncConfig: hasNullActionName ? false : true,
+    }
+  })
+}
+
+function fetchByIpc(keyword: string) {
+  return new Promise<any>((resolve, reject) => {
+    if (!clientStore.ipc) {
+      reject(new Error('当前窗口无法查询拍摄记录'))
+      return
+    }
+    const channel = icpList.takePhoto.getPhotoRecords
+    const timer = setTimeout(() => {
+      clientStore.ipc.removeListener(channel, handler)
+      reject(new Error('搜索超时'))
+    }, 15000)
+    const handler = (_event: any, result: any) => {
+      clearTimeout(timer)
+      clientStore.ipc.removeListener(channel, handler)
+      resolve(result)
+    }
+    clientStore.ipc.on(channel, handler)
+    clientStore.ipc.send(channel, {
+      page: 1,
+      size: 10,
+      goods_art_no: keyword || undefined,
+    })
+  })
+}
+
+async function fetchGoods(keyword = '') {
+  const current = ++seq
+  searching.value = true
+  try {
+    const result = await fetchByIpc(keyword)
+    if (current !== seq) return
+    if (result?.code === 0) {
+      options.value = normalizeList(result.data?.list || [])
+    } else {
+      options.value = []
+    }
+  } catch (e) {
+    if (current === seq) options.value = []
+  } finally {
+    if (current === seq) searching.value = false
+  }
+}
+
+function onRemote(query: string) {
+  if (timer) clearTimeout(timer)
+  timer = setTimeout(() => {
+    fetchGoods((query || '').trim())
+  }, 300)
+}
+
+function onFocus() {
+  if (!options.value.length) fetchGoods('')
+}
+
+function onChange(value: string) {
+  emit('update:modelValue', value || '')
+  const item = options.value.find((row) => row.goods_art_no === value) || null
+  emit('select', item)
+}
+
+onBeforeUnmount(() => {
+  if (timer) clearTimeout(timer)
+})
+</script>
+
+<style scoped lang="scss">
+.goods-option {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+}
+.goods-option__no {
+  font-size: 13px;
+  color: #333;
+}
+.goods-option__meta {
+  font-size: 12px;
+  color: #999;
+}
+</style>

+ 1981 - 0
frontend/src/views/Photography/poster/index.vue

@@ -0,0 +1,1981 @@
+<template>
+  <BlueHeaderBar />
+  <div class="poster-page">
+    <div class="poster-toolbar">
+      <div class="logo-box">
+        <span class="logo-box__label">LOGO</span>
+        <div class="logo-card" :class="{ active: !!logoPath }" @click="openLogoUpload">
+          <img v-if="logoPath" :src="toFileUrl(logoPath)" class="logo-card__img" @error="onLogoError" />
+          <div v-else class="logo-card__empty">上传</div>
+        </div>
+        <div class="logo-list" v-if="otherLogoList.length">
+          <div
+            v-for="item in otherLogoList"
+            :key="item"
+            class="logo-list__item"
+            @click="selectLogo(item)"
+          >
+            <img :src="toFileUrl(item)" />
+          </div>
+        </div>
+        <el-button v-if="logoPath" type="danger" link @click="clearLogo">清除</el-button>
+      </div>
+
+      <div class="toolbar-actions">
+        <el-button v-if="submitting || progressTasks.length" @click="progressVisible = true">查看进度</el-button>
+        <el-tooltip
+          v-if="submitting"
+          content="生成过程中不能关掉湛江场景图窗口"
+          placement="bottom"
+        >
+          <span class="gen-warn-icon">
+            <el-icon><WarningFilled /></el-icon>
+          </span>
+        </el-tooltip>
+      </div>
+
+      <el-button class="gen-btn" type="primary" :loading="submitting" @click="submitGenerate">
+        生成 {{ goodsCount }} 款货 · {{ posterCount }} 张
+      </el-button>
+    </div>
+
+    <div class="poster-main">
+      <section class="list-pane">
+        <div class="list-pane__head">
+          <div class="list-pane__title">
+            待生成的货
+            <span class="list-pane__sub">共 {{ goodsCount }} 款 · 将出 {{ posterCount }} 张场景图</span>
+          </div>
+          <div class="list-pane__actions">
+            <el-button @click="openHistory">历史记录</el-button>
+            <el-button type="primary" @click="openAddGoods">
+              <el-icon class="el-icon--left"><Plus /></el-icon>
+              加一款货
+            </el-button>
+          </div>
+        </div>
+        <div v-if="!goodsList.length" class="list-empty">
+          <div class="empty-card" @click="openAddGoods">
+            <div class="empty-card__plus">+</div>
+            <div class="empty-card__title">添加第一款货</div>
+            <div class="empty-card__desc">先选货号,再编辑样式、主图和配色</div>
+          </div>
+        </div>
+        <div v-else class="goods-grid">
+          <div
+            v-for="row in goodsList"
+            :key="row.id"
+            class="goods-card"
+            @click="openEditGoods(row)"
+          >
+            <div class="goods-card__cover">
+              <el-image v-if="row.hero.image_path" :src="toFileUrl(row.hero.image_path)" fit="cover" />
+              <span v-else>未选图</span>
+            </div>
+            <div class="goods-card__body">
+              <div class="goods-card__no">{{ row.hero.goods_art_no || '未选货号' }}</div>
+              <div class="goods-card__tags">
+                <span class="pill" :class="{ ok: !!row.hero.action_name }">{{ row.hero.action_name || '未选主图' }}</span>
+                <span class="pill" :class="{ ok: !!colorSummary(row) }">{{ colorSummary(row) || '未填配色' }}</span>
+                <span class="pill" :class="{ ok: row.templateIds.length > 0 }">{{ row.templateIds.length }} 套样式</span>
+              </div>
+              <div class="goods-card__ops" @click.stop>
+                <el-button type="primary" size="small" @click="openEditGoods(row)">编辑</el-button>
+                <el-button size="small" @click="removeGoods(row.id)">删除</el-button>
+              </div>
+            </div>
+          </div>
+          <div class="goods-card goods-card--add" @click="openAddGoods">
+            <el-icon :size="28"><Plus /></el-icon>
+            <span>再加一款货</span>
+          </div>
+        </div>
+      </section>
+    </div>
+
+    <el-dialog v-model="addDialogVisible" title="加一款货" width="440px" :close-on-click-modal="false" destroy-on-close>
+      <div class="add-dialog">
+        <div class="field__label">选择货号</div>
+        <GoodsArtNoSearch
+          :model-value="addArtNo"
+          placeholder="搜索拍摄记录中的货号"
+          @update:model-value="onAddArtNoChange"
+          @select="onAddArtSelect"
+        />
+        <div v-if="addArtNo" class="add-dialog__picked">已选:{{ addArtNo }}</div>
+      </div>
+      <template #footer>
+        <el-button @click="addDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmAddGoods">下一步</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog
+      v-if="editDialogVisible"
+      v-model="editDialogVisible"
+      :title="currentGoods ? `编辑 ${currentGoods.hero.goods_art_no}` : '编辑'"
+      width="980px"
+      top="4vh"
+      :close-on-click-modal="false"
+      destroy-on-close
+      class="edit-goods-dialog"
+      @closed="onEditDialogClosed"
+    >
+      <div v-if="currentGoods" class="edit-layout">
+        <div class="edit-col edit-col--styles">
+          <div class="block-title">
+            <span class="block-title__bar"></span>
+            要出的样式
+            <span class="block-title__sub">已选 {{ currentGoods.templateIds.length }}</span>
+          </div>
+          <el-input
+            v-model="styleKeyword"
+            clearable
+            placeholder="搜索样式名称"
+            style="margin-bottom: 12px"
+          />
+          <div v-if="templatesLoading" class="hint">样式加载中…</div>
+          <div v-else-if="!filteredPanelTemplates.length" class="hint">暂无匹配样式</div>
+          <div v-else class="style-grid">
+            <div
+              v-for="tpl in filteredPanelTemplates"
+              :key="tpl.id"
+              class="style-item"
+              :class="{ on: currentGoods.templateIds.includes(tpl.id) }"
+              @click="toggleGoodsTemplate(currentGoods, tpl.id)"
+              @mouseenter="(e: MouseEvent) => showTplPreview(e, tpl)"
+              @mouseleave="hideTplPreview"
+            >
+              <el-image :src="tpl.preview_url" fit="cover" lazy class="style-item__img" />
+              <div class="style-item__name">{{ tpl.name }}</div>
+            </div>
+          </div>
+        </div>
+
+        <div class="edit-col">
+        <div class="block">
+          <div class="block-title">
+            <span class="block-title__bar"></span>
+            主图
+            <span class="locked-no">{{ currentGoods.hero.goods_art_no }}</span>
+          </div>
+          <div class="field">
+            <div class="field__label">拍摄图</div>
+            <div v-if="!heroAngles.length" class="hint">该货号暂无已拍图片</div>
+            <div v-else class="shot-row">
+              <div
+                v-for="angle in heroAngles"
+                :key="angle.action_name + (angle.image_path || '')"
+                class="shot"
+                :class="{
+                  on: currentGoods.hero.action_name === angle.action_name && currentGoods.hero.image_path === angle.image_path,
+                  disabled: !angle.image_path
+                }"
+                @click="selectHeroAngle(angle)"
+              >
+                <el-image v-if="angle.image_path" :src="toFileUrl(angle.image_path)" fit="cover" lazy class="shot__img" />
+                <div v-else class="shot__empty">无图</div>
+                <span class="shot__cap">{{ angle.action_name || '未命名' }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="block">
+          <div class="block-title"><span class="block-title__bar"></span>背景关键词</div>
+          <el-input
+            v-model="currentGoods.keywords"
+            maxlength="100"
+            show-word-limit
+            placeholder="选填,用于调整背景样式,如 丝绸、粉色、清新"
+          />
+        </div>
+
+        <div class="block">
+          <div class="block-title">
+            <span class="block-title__bar"></span>
+            配色
+            <span class="block-title__sub">{{ filledColorCount }} / 8</span>
+          </div>
+          <div v-for="(slot, slotIndex) in currentGoods.colorSlots" :key="slotIndex" class="slot-card">
+            <div class="slot-card__head">
+              <span>颜色 {{ slotIndex + 1 }}</span>
+              <el-button
+                v-if="currentGoods.colorSlots.length > 1"
+                type="danger"
+                link
+                @click="removeColorSlot(slotIndex)"
+              >删除</el-button>
+            </div>
+            <div class="slot-top">
+              <span class="slot-top__label">货号</span>
+              <div class="artno-short">
+                <GoodsArtNoSearch
+                  :model-value="slot.goods_art_no"
+                  placeholder="搜索货号"
+                  @update:model-value="(val) => onSlotGoodsChange(slotIndex, val)"
+                  @select="(item) => onSlotGoodsSelect(slotIndex, item)"
+                />
+              </div>
+              <span class="slot-top__label">颜色</span>
+              <el-input v-model="slot.color_name" class="color-input" placeholder="如 米白" maxlength="20" />
+            </div>
+            <div class="field" v-if="slot.goods_art_no">
+              <div class="field__label">拍摄图</div>
+              <div v-if="!getSlotAngles(slotIndex).length" class="hint">该货号暂无已拍图片</div>
+              <div v-else class="shot-row">
+                <div
+                  v-for="angle in getSlotAngles(slotIndex)"
+                  :key="angle.action_name + (angle.image_path || '')"
+                  class="shot shot--sm"
+                  :class="{
+                    on: slot.action_name === angle.action_name && slot.image_path === angle.image_path,
+                    disabled: !angle.image_path
+                  }"
+                  @click="selectSlotAngle(slotIndex, angle)"
+                >
+                  <el-image v-if="angle.image_path" :src="toFileUrl(angle.image_path)" fit="cover" lazy class="shot__img" />
+                  <div v-else class="shot__empty">无图</div>
+                  <span class="shot__cap">{{ angle.action_name || '未命名' }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div v-if="currentGoods.colorSlots.length < 8" class="add-color-row">
+            <el-button plain @click="addColorSlot">添加颜色</el-button>
+            <el-select v-model="addColorCount" style="width: 92px">
+              <el-option
+                v-for="n in addColorOptions"
+                :key="n"
+                :label="n + '列'"
+                :value="n"
+              />
+            </el-select>
+          </div>
+        </div>
+
+        <div class="block">
+          <div class="block-title"><span class="block-title__bar"></span>商品信息</div>
+          <div v-for="(row, rowIndex) in currentGoods.infoRows" :key="row.key" class="info-row">
+            <div class="info-row__fields">
+              <span class="info-row__label">{{ row.label }}</span>
+              <el-input v-model="row.value" class="info-row__value" :placeholder="row.placeholder || '请输入'" />
+              <el-button
+                v-if="row.key === 'number_mid' && currentGoods.hero.goods_art_no"
+                link
+                type="primary"
+                @click="fillInfoWithHero(rowIndex)"
+              >填入主图货号</el-button>
+            </div>
+          </div>
+        </div>
+        </div>
+      </div>
+      <template #footer>
+        <el-button @click="editDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="editDialogVisible = false">完成</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog
+      v-model="progressVisible"
+      title="生成进度"
+      width="680px"
+      custom-class="poster-progress-dialog"
+      :close-on-click-modal="false"
+      destroy-on-close
+      @close="onProgressClose"
+    >
+      <div v-if="createErrorMsg" class="pg-fail">
+        <div class="pg-fail__title">任务没有创建成功</div>
+        <div class="pg-fail__desc">{{ createErrorMsg }}</div>
+        <div class="pg-fail__hint">请检查登录状态或稍后重试。详细返回已打印在控制台。</div>
+      </div>
+      <template v-else>
+        <div class="pg-head">
+          <div class="pg-head__meta">
+            <span>{{ submitPhase || (submitting ? '正在生成中' : '生成结束') }}</span>
+            <span class="pg-head__count">{{ doneTaskCount }} / {{ progressTasks.length }} 完成</span>
+          </div>
+          <el-progress
+            :percentage="overallPercent"
+            :stroke-width="10"
+            :format="formatPercentText"
+            :color="overallPercent >= 100 ? '#67c23a' : '#2957ff'"
+          />
+        </div>
+        <div v-if="submitting && !progressTasks.length" class="pg-submit">正在提交任务,请稍候…</div>
+        <div v-else class="pg-list">
+          <div v-for="task in progressTasks" :key="String(task.id)" class="pg-card">
+            <el-image
+              v-if="task.image"
+              :src="task.image"
+              fit="contain"
+              class="pg-card__img"
+              :preview-src-list="[task.image]"
+            />
+            <div v-else class="pg-card__img is-wait">{{ task.fail ? '失败' : '生成中' }}</div>
+            <div class="pg-card__body">
+              <div class="pg-card__top">
+                <div class="pg-card__name">{{ task.name }}</div>
+                <div class="pg-card__status" :class="task.statusClass">{{ task.statusText }}</div>
+              </div>
+              <el-progress
+                :percentage="displayPercent(task)"
+                :stroke-width="8"
+                :format="formatPercentText"
+                :status="task.fail ? 'exception' : (task.status === 2 && task.image ? 'success' : undefined)"
+                :color="task.fail ? '#f56c6c' : '#2957ff'"
+              />
+              <div class="pg-card__foot">
+                <span class="pg-card__time" v-if="task.pending && !task.fail">预计剩余 {{ formatRemain(task.remain) }}</span>
+                <el-button v-if="task.image" type="primary" link @click="saveOneImage(task.image, task.name)">保存到本地</el-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </template>
+      <template #footer>
+        <el-button
+          v-if="!createErrorMsg && doneTaskCount"
+          type="primary"
+          @click="saveDoneImages"
+        >保存已完成图片</el-button>
+        <el-button @click="progressVisible = false">{{ submitting && !createErrorMsg ? '后台生成' : '关闭' }}</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog
+      v-model="historyVisible"
+      title="湛江场景图历史记录"
+      width="920px"
+      top="6vh"
+      destroy-on-close
+      custom-class="history-dialog"
+      @open="loadHistory(true)"
+    >
+      <div v-loading="historyLoading" class="his-wrap">
+        <el-empty v-if="!historyLoading && !historyList.length" description="暂无历史记录" />
+        <div v-else class="his-grid" @scroll="onHistoryScroll">
+          <div v-for="item in historyList" :key="item.id || historyImage(item)" class="his-card">
+            <div class="his-card__pic">
+              <el-image
+                :src="historyImage(item)"
+                fit="contain"
+                lazy
+                class="his-card__img"
+                :preview-src-list="historyImage(item) ? [historyImage(item)] : []"
+              />
+            </div>
+            <div class="his-card__meta">
+              <span class="his-card__time">{{ historyTime(item) }}</span>
+              <el-button v-if="historyImage(item)" type="primary" link @click.stop="saveOneImage(historyImage(item), historyTime(item) || '湛江场景图')">保存</el-button>
+            </div>
+          </div>
+          <div class="his-more">
+            <span v-if="historyLoadingMore">加载中…</span>
+            <span v-else-if="historyFinished && historyList.length">没有更多了</span>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+
+    <div
+      v-if="tplPreview"
+      class="tpl-hover-preview"
+      :style="{ left: tplPreview.x + 'px', top: tplPreview.y + 'px' }"
+    >
+      <img :src="tplPreview.src" alt="" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { ElMessage, ElNotification } from 'element-plus'
+import { Plus, WarningFilled } from '@element-plus/icons-vue'
+import BlueHeaderBar from '@/components/header-bar/blue-header.vue'
+import GoodsArtNoSearch from './GoodsArtNoSearch.vue'
+import { defaultPosterInfoRows, fetchPosterHistory, fetchPosterTemplates, normalizePosterHistory, pickGenerateIds, searchPosterProgress } from '@/apis/poster'
+import type { PosterTemplate } from '@/apis/poster'
+import client from '@/stores/modules/client'
+import useUserInfo from '@/stores/modules/user'
+import icpList from '@/utils/ipc'
+
+interface AngleItem {
+  action_name: string
+  image_path: string
+}
+
+interface ColorSlot {
+  goods_art_no: string
+  action_name: string
+  image_path: string
+  color_name: string
+  color_hex: string
+}
+
+interface InfoRow {
+  key: 'number_mid' | 'size_mid' | 'number_big' | 'size_big' | 'box'
+  label: string
+  value: string
+  placeholder?: string
+}
+
+interface GoodsJob {
+  id: string
+  templateIds: string[]
+  keywords: string
+  hero: {
+    goods_art_no: string
+    action_name: string
+    image_path: string
+  }
+  colorSlots: ColorSlot[]
+  infoRows: InfoRow[]
+}
+
+interface ProgressTask {
+  id: string | number
+  name: string
+  remain: number
+  status: number
+  pending: boolean
+  fail: boolean
+  image: string
+  statusText: string
+  statusClass: string
+  startedAt: number
+  fakePercent: number
+}
+
+const clientStore = client()
+const userStore = useUserInfo()
+
+const templates = ref<PosterTemplate[]>([])
+const templatesLoading = ref(false)
+const templateKeywords = ref('')
+const tplPreview = ref<{ src: string; x: number; y: number } | null>(null)
+let keywordsTimer: ReturnType<typeof setTimeout> | null = null
+const logoPath = ref('')
+const logoList = ref<string[]>([])
+const otherLogoList = computed(() => logoList.value.filter((item) => item && item !== logoPath.value))
+const goodsList = ref<GoodsJob[]>([])
+const activeGoodsId = ref('')
+const addDialogVisible = ref(false)
+const addArtNo = ref('')
+const addArtItem = ref<any>(null)
+const editDialogVisible = ref(false)
+const addColorCount = ref(1)
+const addColorOptions = computed(() => {
+  const remain = 8 - (currentGoods.value?.colorSlots.length || 0)
+  return Array.from({ length: Math.max(remain, 0) }, (_, i) => i + 1)
+})
+const submitting = ref(false)
+const progressVisible = ref(false)
+const submitPhase = ref('')
+const progressTasks = ref<ProgressTask[]>([])
+const historyVisible = ref(false)
+const historyLoading = ref(false)
+const historyLoadingMore = ref(false)
+const historyFinished = ref(false)
+const historyList = ref<any[]>([])
+const historyPage = ref(1)
+const historyPageSize = 20
+const historyTotal = ref(0)
+const createErrorMsg = ref('')
+const goodsAngleMap = ref<Record<string, AngleItem[]>>({})
+
+let countdownTimer: ReturnType<typeof setInterval> | null = null
+let pollTimer: ReturnType<typeof setTimeout> | null = null
+let pollActive = false
+let pollBusy = false
+let generateIds: Array<string | number> = []
+let generateChannel = 'poster'
+
+const currentGoods = computed(() => goodsList.value.find((item) => item.id === activeGoodsId.value) || null)
+const heroAngles = computed(() => goodsAngleMap.value[currentGoods.value?.hero.goods_art_no] || [])
+const filledColorCount = computed(() => currentGoods.value?.colorSlots.filter((slot) => slot.goods_art_no).length || 0)
+const styleKeyword = ref('')
+const filteredPanelTemplates = computed(() => {
+  const keyword = styleKeyword.value.trim().toLowerCase()
+  if (!keyword) return templates.value
+  return templates.value.filter((item) => (item.name || '').toLowerCase().includes(keyword))
+})
+const goodsCount = computed(() => goodsList.value.length)
+const posterCount = computed(() => goodsList.value.reduce((sum, item) => sum + item.templateIds.length, 0))
+const doneTaskCount = computed(() => progressTasks.value.filter((task) => task.status === 2 && task.image).length)
+const overallPercent = computed(() => {
+  if (!progressTasks.value.length) return 0
+  const sum = progressTasks.value.reduce((total, task) => total + displayPercent(task), 0)
+  return Math.round((sum / progressTasks.value.length) * 10) / 10
+})
+const userId = computed(() => (userStore.userInfo as any)?.id || 'guest')
+const logoCacheKey = computed(() => `poster_logo_path_${userId.value}`)
+const draftCacheKey = computed(() => `poster_session_draft_v3_${userId.value}`)
+
+function uid() {
+  return `g_${Date.now()}_${Math.floor(Math.random() * 1e6)}`
+}
+
+function emptySlot(): ColorSlot {
+  return { goods_art_no: '', action_name: '', image_path: '', color_name: '', color_hex: '' }
+}
+
+function defaultInfoRows(): InfoRow[] {
+  return defaultPosterInfoRows()
+}
+
+function findPosterTemplate(id: string) {
+  return templates.value.find((item) => item.id === String(id)) || null
+}
+
+function createGoods(): GoodsJob {
+  return {
+    id: uid(),
+    templateIds: [],
+    keywords: '',
+    hero: { goods_art_no: '', action_name: '', image_path: '' },
+    colorSlots: [emptySlot()],
+    infoRows: defaultInfoRows(),
+  }
+}
+
+function ensureGoods(raw: any): GoodsJob {
+  const goods = createGoods()
+  if (!raw || typeof raw !== 'object') return goods
+  goods.id = raw.id || goods.id
+  const ids = Array.isArray(raw.templateIds)
+    ? raw.templateIds
+    : (raw.template_id ? [raw.template_id] : [])
+  goods.templateIds = ids.map((id: string | number) => String(id)).filter(Boolean)
+  goods.keywords = raw.keywords || ''
+  goods.hero = {
+    goods_art_no: raw.hero?.goods_art_no || '',
+    action_name: raw.hero?.action_name || '',
+    image_path: raw.hero?.image_path || '',
+  }
+  goods.colorSlots = Array.isArray(raw.colorSlots) && raw.colorSlots.length
+    ? raw.colorSlots.map((slot: any) => ({
+      goods_art_no: slot?.goods_art_no || '',
+      action_name: slot?.action_name || '',
+      image_path: slot?.image_path || '',
+      color_name: slot?.color_name || '',
+      color_hex: slot?.color_hex || '',
+    }))
+    : [emptySlot()]
+  if (goods.hero.goods_art_no) {
+    goods.colorSlots.forEach((slot) => {
+      if (!slot.goods_art_no) slot.goods_art_no = goods.hero.goods_art_no
+    })
+  }
+  const rawInfos = Array.isArray(raw.infoRows) ? raw.infoRows : []
+  const defaults = defaultInfoRows()
+  goods.infoRows = defaults.map((item) => {
+    const hit = rawInfos.find((row: any) => row?.key === item.key)
+    return {
+      ...item,
+      value: hit?.value || '',
+    }
+  })
+  return goods
+}
+
+function colorSummary(row: GoodsJob) {
+  const names = row.colorSlots.map((slot) => slot.color_name).filter(Boolean)
+  return names.join(' / ')
+}
+
+function infoSummary(row: GoodsJob) {
+  const values = row.infoRows.filter((item) => item.label.trim() && item.value.trim()).map((item) => item.value)
+  return values.slice(0, 3).join(' · ')
+}
+
+function selectedTemplateSummary(row: GoodsJob) {
+  if (!row.templateIds.length) return '未选,请在右侧勾选'
+  const names = row.templateIds
+    .map((id) => findPosterTemplate(id)?.name)
+    .filter(Boolean)
+  if (!names.length) return `已选 ${row.templateIds.length} 套`
+  if (names.length <= 2) return names.join('、')
+  return `${names.slice(0, 2).join('、')} 等${names.length}套`
+}
+
+function rowClassName({ row }: { row: GoodsJob }) {
+  return row.id === activeGoodsId.value ? 'is-current' : ''
+}
+
+function onRowClick(row: GoodsJob) {
+  activeGoodsId.value = row.id
+  ensureGoodsAngles(row.hero.goods_art_no)
+}
+
+function toggleGoodsTemplate(row: GoodsJob, templateId: string) {
+  const idx = row.templateIds.indexOf(templateId)
+  if (idx >= 0) row.templateIds.splice(idx, 1)
+  else row.templateIds.push(templateId)
+}
+
+function showTplPreview(e: MouseEvent, tpl: PosterTemplate) {
+  const src = tpl.preview_url || tpl.image_url
+  if (!src) {
+    tplPreview.value = null
+    return
+  }
+  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
+  const size = 200
+  let x = rect.right + 8
+  let y = rect.top
+  if (x + size > window.innerWidth - 8) x = rect.left - size - 8
+  if (y + size > window.innerHeight - 8) y = window.innerHeight - size - 8
+  if (y < 8) y = 8
+  tplPreview.value = { src, x, y }
+}
+
+function hideTplPreview() {
+  tplPreview.value = null
+}
+
+function toFileUrl(path: string) {
+  if (!path) return ''
+  if (/^(data:|https?:|file:)/i.test(path)) return path
+  return 'file:///' + path.replace(/\\/g, '/')
+}
+
+function pickAngles(item: any): AngleItem[] {
+  if (!item?.items) return []
+  return item.items.map((image: any) => ({
+    action_name: image.action_name || '',
+    image_path: image.PhotoRecord?.image_path || image.image_path || '',
+  }))
+}
+
+function firstValidAngle(angles: AngleItem[]) {
+  return angles.find((item) => item.image_path) || null
+}
+
+function cacheGoodsAngles(item: any) {
+  if (!item?.goods_art_no) return
+  goodsAngleMap.value = {
+    ...goodsAngleMap.value,
+    [item.goods_art_no]: pickAngles(item),
+  }
+}
+
+function fetchPhotoByArtNo(artNo: string) {
+  return new Promise<any>((resolve, reject) => {
+    if (!clientStore.ipc || !artNo) {
+      reject(new Error('无法查询拍摄记录'))
+      return
+    }
+    const channel = icpList.takePhoto.getPhotoRecords
+    const timer = setTimeout(() => {
+      clientStore.ipc.removeListener(channel, handler)
+      reject(new Error('搜索超时'))
+    }, 15000)
+    const handler = (_event: any, result: any) => {
+      clearTimeout(timer)
+      clientStore.ipc.removeListener(channel, handler)
+      resolve(result)
+    }
+    clientStore.ipc.on(channel, handler)
+    clientStore.ipc.send(channel, {
+      page: 1,
+      size: 10,
+      goods_art_no: artNo,
+    })
+  })
+}
+
+async function ensureGoodsAngles(artNo: string) {
+  if (!artNo || (goodsAngleMap.value[artNo] && goodsAngleMap.value[artNo].length)) return
+  try {
+    const result = await fetchPhotoByArtNo(artNo)
+    const item = (result?.data?.list || []).find((row: any) => row.goods_art_no === artNo)
+    if (item) cacheGoodsAngles(item)
+  } catch { /* ignore */ }
+}
+
+function selectHeroAngle(angle: AngleItem) {
+  if (!angle.image_path || !currentGoods.value) return
+  currentGoods.value.hero.action_name = angle.action_name
+  currentGoods.value.hero.image_path = angle.image_path
+}
+
+function getSlotAngles(index: number) {
+  const artNo = currentGoods.value?.colorSlots[index]?.goods_art_no
+  if (!artNo) return []
+  return goodsAngleMap.value[artNo] || []
+}
+
+function onSlotGoodsChange(index: number, value: string) {
+  const slot = currentGoods.value?.colorSlots[index]
+  if (!slot) return
+  slot.goods_art_no = value
+  slot.action_name = ''
+  slot.image_path = ''
+}
+
+function onSlotGoodsSelect(index: number, item: any) {
+  const slot = currentGoods.value?.colorSlots[index]
+  if (!slot) return
+  if (!item) {
+    slot.action_name = ''
+    slot.image_path = ''
+    return
+  }
+  cacheGoodsAngles(item)
+  slot.goods_art_no = item.goods_art_no
+  const first = firstValidAngle(pickAngles(item))
+  slot.action_name = first?.action_name || ''
+  slot.image_path = first?.image_path || ''
+}
+
+function selectSlotAngle(index: number, angle: AngleItem) {
+  const slot = currentGoods.value?.colorSlots[index]
+  if (!slot || !angle.image_path) return
+  slot.action_name = angle.action_name
+  slot.image_path = angle.image_path
+}
+
+function addColorSlot() {
+  if (!currentGoods.value) return
+  const remain = 8 - currentGoods.value.colorSlots.length
+  if (remain <= 0) return
+  const count = Math.min(addColorCount.value || 1, remain)
+  for (let i = 0; i < count; i++) {
+    const slot = emptySlot()
+    slot.goods_art_no = currentGoods.value.hero.goods_art_no
+    currentGoods.value.colorSlots.push(slot)
+  }
+  if (addColorCount.value > remain - count) addColorCount.value = Math.max(remain - count, 1)
+}
+
+function openAddGoods() {
+  addArtNo.value = ''
+  addArtItem.value = null
+  addDialogVisible.value = true
+}
+
+function onAddArtNoChange(value: string) {
+  addArtNo.value = value
+  if (!value) addArtItem.value = null
+}
+
+function onAddArtSelect(item: any) {
+  addArtItem.value = item
+  addArtNo.value = item?.goods_art_no || addArtNo.value
+}
+
+function fillGoodsFromItem(goods: GoodsJob, item: any) {
+  const artNo = item?.goods_art_no || addArtNo.value
+  goods.hero.goods_art_no = artNo
+  if (item) {
+    cacheGoodsAngles(item)
+    const first = firstValidAngle(pickAngles(item))
+    goods.hero.action_name = first?.action_name || ''
+    goods.hero.image_path = first?.image_path || ''
+  }
+  const info = goods.infoRows.find((row) => row.key === 'number_mid' && !row.value)
+  if (info) info.value = artNo
+  if (goods.colorSlots[0] && !goods.colorSlots[0].goods_art_no) {
+    goods.colorSlots[0].goods_art_no = artNo
+  }
+}
+
+function confirmAddGoods() {
+  if (!addArtNo.value) {
+    ElMessage.warning('请选择货号')
+    return
+  }
+  if (goodsList.value.some((item) => item.hero.goods_art_no === addArtNo.value)) {
+    ElMessage.warning('该货号已添加')
+    return
+  }
+  const goods = createGoods()
+  fillGoodsFromItem(goods, addArtItem.value)
+  goodsList.value.push(goods)
+  activeGoodsId.value = goods.id
+  addDialogVisible.value = false
+  editDialogVisible.value = true
+}
+
+function onEditDialogClosed() {
+  hideTplPreview()
+  styleKeyword.value = ''
+}
+
+function openEditGoods(row: GoodsJob) {
+  activeGoodsId.value = row.id
+  ensureGoodsAngles(row.hero.goods_art_no)
+  row.colorSlots.forEach((slot) => {
+    if (slot.goods_art_no) ensureGoodsAngles(slot.goods_art_no)
+  })
+  editDialogVisible.value = true
+}
+
+function removeGoods(id: string) {
+  goodsList.value = goodsList.value.filter((item) => item.id !== id)
+  if (activeGoodsId.value === id) activeGoodsId.value = goodsList.value[0]?.id || ''
+}
+
+function removeColorSlot(index: number) {
+  if (!currentGoods.value || currentGoods.value.colorSlots.length <= 1) return
+  currentGoods.value.colorSlots.splice(index, 1)
+}
+
+function fillInfoWithHero(index: number) {
+  if (!currentGoods.value) return
+  currentGoods.value.infoRows[index].value = currentGoods.value.hero.goods_art_no || ''
+}
+
+function saveLogoCache(path: string) {
+  try { localStorage.setItem(logoCacheKey.value, path || '') } catch { /* ignore */ }
+}
+
+function loadLogoCache() {
+  try { return localStorage.getItem(logoCacheKey.value) || '' } catch { return '' }
+}
+
+function hasGoodsNo(item: GoodsJob) {
+  return !!(item && item.hero && item.hero.goods_art_no)
+}
+
+function saveDraft() {
+  try {
+    const list = goodsList.value.filter(hasGoodsNo)
+    localStorage.setItem(draftCacheKey.value, JSON.stringify({
+      logoPath: logoPath.value,
+      goodsList: list,
+      activeGoodsId: list.some((item) => item.id === activeGoodsId.value) ? activeGoodsId.value : (list[0]?.id || ''),
+    }))
+  } catch { /* ignore */ }
+}
+
+function loadDraft() {
+  try {
+    const raw = localStorage.getItem(draftCacheKey.value)
+    if (!raw) return
+    const data = JSON.parse(raw)
+    const list = (data.goodsList || data.posters || []).map(ensureGoods).filter(hasGoodsNo)
+    goodsList.value = list
+    activeGoodsId.value = list.some((item) => item.id === (data.activeGoodsId || data.activePosterId))
+      ? (data.activeGoodsId || data.activePosterId)
+      : (list[0]?.id || '')
+    if (data.logoPath) logoPath.value = data.logoPath
+    goodsList.value.forEach((item) => ensureGoodsAngles(item.hero.goods_art_no))
+  } catch { /* ignore */ }
+}
+
+function selectLogo(path: string) {
+  logoPath.value = path
+  saveLogoCache(path)
+}
+
+function clearLogo() {
+  logoPath.value = ''
+  saveLogoCache('')
+}
+
+function onLogoError() {
+  logoPath.value = ''
+  saveLogoCache('')
+  ElMessage.warning('LOGO 文件不存在或无法加载,请重新上传')
+}
+
+function openLogoUpload() {
+  clientStore.ipc.removeAllListeners(icpList.utils.openImage)
+  clientStore.ipc.send(icpList.utils.openImage)
+  clientStore.ipc.on(icpList.utils.openImage, async (_event: any, result: any) => {
+    clientStore.ipc.removeAllListeners(icpList.utils.openImage)
+    const filePath = result?.filePath
+    if (!filePath) return
+    try {
+      const saved = await clientStore.ipc.invoke(icpList.generate.addLogo, { logo_path: filePath })
+      const path = saved?.data?.logo || filePath
+      logoPath.value = path
+      saveLogoCache(path)
+      await loadLogoList(false)
+    } catch {
+      logoPath.value = filePath
+      saveLogoCache(filePath)
+    }
+  })
+}
+
+async function loadLogoList(applyCache = true) {
+  try {
+    const result = await clientStore.ipc.invoke(icpList.generate.getLogoList)
+    logoList.value = result?.data || []
+    if (!applyCache) return
+    const cached = loadLogoCache()
+    if (cached) logoPath.value = cached
+    else if (!logoPath.value && logoList.value.length) {
+      logoPath.value = logoList.value[0]
+      saveLogoCache(logoPath.value)
+    }
+  } catch {
+    logoList.value = []
+  }
+}
+
+function goodsName(goods: GoodsJob, index: number) {
+  return goods.hero.goods_art_no || `货${index + 1}`
+}
+
+function validateGoods(goods: GoodsJob, name: string) {
+  if (!goods.hero.goods_art_no || !goods.hero.action_name || !goods.hero.image_path) {
+    return `${name}请完整填写主图货号和拍摄图`
+  }
+  const slots = goods.colorSlots.filter((slot) => slot.goods_art_no || slot.action_name || slot.image_path || slot.color_name)
+  if (!slots.length) return `${name}请至少完整填写 1 个配色`
+  for (let i = 0; i < slots.length; i++) {
+    const slot = slots[i]
+    if (!slot.goods_art_no || !slot.action_name || !slot.image_path || !slot.color_name) {
+      return `${name}第${i + 1}个配色未填写完整`
+    }
+  }
+  const infos = goods.infoRows.filter((row) => row.label.trim() || row.value.trim())
+  if (!infos.length) return `${name}请至少填写一行商品信息`
+  for (const row of infos) {
+    if (!row.label.trim() || !row.value.trim()) return `${name}商品信息需要同时填写名称和内容`
+  }
+  if (!goods.templateIds.length) return `${name}请至少勾选 1 套要出的样式`
+  for (const id of goods.templateIds) {
+    if (!findPosterTemplate(id)?.image_url) return `${name}所选样式缺少参考图,请重新勾选`
+  }
+  return ''
+}
+
+function validateForm() {
+  if (!logoPath.value) return '请先选择 LOGO'
+  if (!goodsList.value.length) return '请至少加一款货'
+  for (let i = 0; i < goodsList.value.length; i++) {
+    const msg = validateGoods(goodsList.value[i], goodsName(goodsList.value[i], i))
+    if (msg) return msg
+  }
+  return ''
+}
+
+function infoPair(goods: GoodsJob, key: InfoRow['key'], fallbackName: string) {
+  const row = goods.infoRows.find((item) => item.key === key)
+  return {
+    name: fallbackName,
+    value: (row?.value || '').trim(),
+  }
+}
+
+function buildShoesComposeData() {
+  const list: any[] = []
+  goodsList.value.forEach((goods) => {
+    const images = goods.colorSlots
+      .filter((slot) => slot.image_path && slot.color_name)
+      .map((slot) => ({
+        image_path: slot.image_path,
+        color: slot.color_name,
+      }))
+    goods.templateIds.forEach((templateId) => {
+      const tpl = findPosterTemplate(templateId)
+      if (!tpl?.image_url) return
+      const item: any = {
+        logo: logoPath.value,
+        reference_image: tpl.image_url,
+        number_mid: infoPair(goods, 'number_mid', '货号(中童)'),
+        size_mid: infoPair(goods, 'size_mid', '尺码 Size'),
+        number_big: infoPair(goods, 'number_big', '货号(大童)'),
+        size_big: infoPair(goods, 'size_big', '尺码 Size'),
+        box: infoPair(goods, 'box', '装箱 Box'),
+        image_path: goods.hero.image_path,
+        images,
+      }
+      const keywords = (goods.keywords || '').trim()
+      if (keywords) item.keywords = keywords
+      list.push(item)
+    })
+  })
+  return list
+}
+
+function formatRemain(remain: number) {
+  const n = Math.max(0, remain || 0)
+  const m = Math.floor(n / 60)
+  const s = n % 60
+  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+}
+
+function calcFakePercent(task: ProgressTask) {
+  if (task.fail || task.status === -1) return 0
+  if (task.status === 2 && task.image) return 100
+  const elapsed = Math.max(0, (Date.now() - (task.startedAt || Date.now())) / 1000)
+  let p = 2
+  if (elapsed <= 18) {
+    p = 2 + (elapsed / 18) * 78
+  } else if (elapsed <= 48) {
+    p = 80 + ((elapsed - 18) / 30) * 15
+  } else {
+    p = 95 + Math.floor((elapsed - 48) / 0.8) * 0.1
+  }
+  return Math.min(99.9, Math.round(p * 10) / 10)
+}
+
+function displayPercent(task: ProgressTask) {
+  if (task.fail || task.status === -1) return 0
+  if (task.status === 2 && task.image) return 100
+  return task.fakePercent ?? calcFakePercent(task)
+}
+
+function formatPercentText(val: number) {
+  if (val >= 100) return '100%'
+  return `${Number(val).toFixed(1)}%`
+}
+
+function taskStatusMeta(task: ProgressTask) {
+  if (task.fail || task.status === -1) return { statusText: '失败', statusClass: 'is-fail' }
+  if (task.status === 2 && task.image) return { statusText: '已完成', statusClass: 'is-done' }
+  if (task.remain <= 0) return { statusText: '即将完成…', statusClass: 'is-wait' }
+  if (task.status === 1) return { statusText: '生成中', statusClass: 'is-wait' }
+  return { statusText: '排队中', statusClass: 'is-wait' }
+}
+
+function refreshTaskMeta() {
+  progressTasks.value = progressTasks.value.map((task) => ({ ...task, ...taskStatusMeta(task) }))
+}
+
+function stopTimers() {
+  pollActive = false
+  pollBusy = false
+  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
+  if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
+}
+
+function startCountdown() {
+  if (countdownTimer) clearInterval(countdownTimer)
+  countdownTimer = setInterval(() => {
+    progressTasks.value = progressTasks.value.map((task) => {
+      if (task.fail || task.status === -1) return { ...task, fakePercent: 0, remain: 0 }
+      if (task.status === 2 && task.image) return { ...task, fakePercent: 100, remain: 0 }
+      const elapsed = Math.max(0, (Date.now() - (task.startedAt || Date.now())) / 1000)
+      return {
+        ...task,
+        fakePercent: calcFakePercent(task),
+        remain: Math.max(0, Math.ceil(30 - elapsed)),
+      }
+    })
+    refreshTaskMeta()
+  }, 200)
+}
+
+async function pollOnce() {
+  if (!generateIds.length) return
+  try {
+    const res: any = await searchPosterProgress({
+      generate_ids: generateIds,
+      type: generateChannel,
+    })
+    const list = Array.isArray(res?.data) ? res.data : (res?.data ? [res.data] : [])
+    const map: Record<string, any> = {}
+    list.forEach((item: any) => {
+      if (item && item.id != null) {
+        map[item.id] = item
+        map[String(item.id)] = item
+      }
+    })
+    progressTasks.value = progressTasks.value.map((task) => {
+      const item = map[task.id] ?? map[String(task.id)]
+      if (!item) return task
+      const st = Number(item.status)
+      if (st === 2) {
+        const urls = item.result_image_urls || []
+        const img = urls[0] || item.image || item.image_url || ''
+        if (!img) return { ...task, status: 1, pending: true, fakePercent: calcFakePercent(task) }
+        return { ...task, status: 2, pending: false, fail: false, image: img, remain: 0, fakePercent: 100 }
+      }
+      if (st === -1) return { ...task, status: -1, pending: false, fail: true, remain: 0, fakePercent: 0 }
+      return { ...task, status: Number.isNaN(st) ? task.status : st, pending: true }
+    })
+    refreshTaskMeta()
+    const pending = progressTasks.value.some((task) => task.status === 0 || task.status === 1 || task.pending)
+    const allFail = progressTasks.value.length > 0 && progressTasks.value.every((task) => task.fail || task.status === -1)
+    if (allFail) {
+      stopTimers()
+      submitting.value = false
+      submitPhase.value = ''
+      setPosterWindowClosable(true)
+      ElMessage.error('生成失败,请稍后重试')
+      return
+    }
+    if (!pending) {
+      stopTimers()
+      submitting.value = false
+      submitPhase.value = ''
+      setPosterWindowClosable(true)
+      const okCount = progressTasks.value.filter((task) => task.status === 2 && task.image).length
+      notifyGenerateDone(okCount)
+    }
+  } catch (e: any) {
+    const msg = e?.message || ''
+    const st = e?.status || e?.code
+    if (st === 401 || /未登录|登录状态已失效|请先登录/.test(msg)) {
+      stopTimers()
+      submitting.value = false
+      submitPhase.value = ''
+      setPosterWindowClosable(true)
+      ElMessage.error(msg || '登录已失效,请重新登录后再试')
+    }
+  }
+}
+
+function startPoll() {
+  stopTimers()
+  pollActive = true
+  startCountdown()
+  const tick = async () => {
+    if (!pollActive) return
+    if (pollBusy) return
+    pollBusy = true
+    try {
+      await pollOnce()
+    } finally {
+      pollBusy = false
+      if (pollActive) pollTimer = setTimeout(tick, 2000)
+    }
+  }
+  tick()
+}
+
+function flattenTaskNames() {
+  const names: string[] = []
+  goodsList.value.forEach((goods, index) => {
+    goods.templateIds.forEach((id) => {
+      const tpl = findPosterTemplate(id)
+      names.push(`${goodsName(goods, index)} · ${tpl?.name || '样式'}`)
+    })
+  })
+  return names
+}
+
+async function submitGenerate() {
+  if (submitting.value) return
+  const error = validateForm()
+  if (error) {
+    console.log('[湛江场景图] 校验未通过', error)
+    ElMessage.warning(error)
+    return
+  }
+  submitting.value = true
+  progressVisible.value = true
+  createErrorMsg.value = ''
+  setPosterWindowClosable(false)
+  submitPhase.value = '正在提交任务…'
+  progressTasks.value = []
+  try {
+    const payload = {
+      data: buildShoesComposeData(),
+    }
+    console.log('[湛江场景图] 提交 payload', JSON.parse(JSON.stringify(payload)))
+    if (!payload.data.length) throw new Error('没有可提交的湛江场景图任务')
+    const result = await clientStore.ipc.invoke(icpList.generate.generatePoster, payload)
+    console.log('[湛江场景图] Python 返回', result)
+    if (!result || result.code !== 0) throw new Error(result?.msg || result?.message || '湛江场景图任务创建失败')
+    const picked = pickGenerateIds(result.data)
+    console.log('[湛江场景图] 解析任务 ID', picked, 'data=', result.data)
+    generateIds = picked.ids
+    generateChannel = picked.channel || result.data?.generate_channel || 'poster'
+    if (!generateIds.length) throw new Error('未返回生成任务 ID')
+    const names = flattenTaskNames()
+    const now = Date.now()
+    progressTasks.value = generateIds.map((id: string | number, index: number) => ({
+      id,
+      name: names[index] || `任务 ${index + 1}`,
+      remain: 30,
+      status: 0,
+      pending: true,
+      fail: false,
+      image: '',
+      statusText: '排队中',
+      statusClass: 'is-wait',
+      startedAt: now,
+      fakePercent: 2,
+    }))
+    submitPhase.value = '正在生成中'
+    startPoll()
+  } catch (e: any) {
+    console.log('[湛江场景图] 创建失败', e, e?.message, e?.msg, e)
+    submitting.value = false
+    const msg = e?.message || e?.msg || '湛江场景图任务创建失败'
+    createErrorMsg.value = msg
+    submitPhase.value = ''
+    setPosterWindowClosable(true)
+  }
+}
+
+function setPosterWindowClosable(closable: boolean) {
+  try {
+    clientStore.ipc.invoke(icpList.utils.setWindowClosable, {
+      id: 'posterGenerate',
+      closable,
+    })
+  } catch { /* ignore */ }
+}
+
+function notifyGenerateDone(okCount: number) {
+  progressVisible.value = true
+  const title = '湛江场景图生成完成'
+  const message = `已完成 ${okCount} 张湛江场景图`
+  ElNotification({
+    title,
+    message,
+    type: 'success',
+    duration: 8000,
+  })
+  try {
+    if (typeof Notification === 'undefined') return
+    if (Notification.permission === 'granted') {
+      new Notification(title, { body: message })
+    } else if (Notification.permission !== 'denied') {
+      Notification.requestPermission().then((permission) => {
+        if (permission === 'granted') new Notification(title, { body: message })
+      })
+    }
+  } catch { /* ignore */ }
+}
+
+function onProgressClose() {
+  if (!submitting.value) stopTimers()
+}
+
+function historyImage(item: any) {
+  const urls = item?.result_image_urls || item?.images || []
+  return urls[0] || item?.image || item?.image_url || item?.result_image || ''
+}
+
+function historyTime(item: any) {
+  return item.day || item.created_at || item.create_time || item.updated_at || ''
+}
+
+async function saveOneImage(url: string, name?: string) {
+  if (!url) return
+  try {
+    const result = await clientStore.ipc.invoke(icpList.utils.saveRemoteImage, {
+      url,
+      defaultName: `${String(name || '湛江场景图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)}.png`,
+    })
+    if (result?.code === 0) ElMessage.success('已保存到 ' + result.path)
+    else if (result?.msg && result.msg !== '已取消保存') ElMessage.error(result.msg)
+  } catch (e: any) {
+    ElMessage.error(e?.message || '保存失败')
+  }
+}
+
+async function saveDoneImages() {
+  const items = progressTasks.value
+    .filter((task) => task.image)
+    .map((task) => ({ url: task.image, name: task.name }))
+  if (!items.length) {
+    ElMessage.warning('还没有可保存的图片')
+    return
+  }
+  try {
+    const result = await clientStore.ipc.invoke(icpList.utils.saveRemoteImages, { items })
+    if (result?.code === 0) ElMessage.success(`已保存 ${result.count || items.length} 张到 ${result.path}`)
+    else if (result?.msg && result.msg !== '已取消保存') ElMessage.error(result.msg)
+  } catch (e: any) {
+    ElMessage.error(e?.message || '保存失败')
+  }
+}
+
+async function loadHistory(reset = false) {
+  if (!reset && (historyLoadingMore.value || historyFinished.value || historyLoading.value)) return
+  if (reset) {
+    historyPage.value = 1
+    historyFinished.value = false
+    historyList.value = []
+    historyLoading.value = true
+  } else {
+    historyLoadingMore.value = true
+  }
+  try {
+    const res: any = await fetchPosterHistory({
+      current: historyPage.value,
+      size: historyPageSize,
+    }, false)
+    const parsed = normalizePosterHistory(res)
+    historyList.value = reset ? parsed.list : historyList.value.concat(parsed.list)
+    historyTotal.value = parsed.total
+    if (!parsed.list.length || parsed.list.length < historyPageSize || (parsed.total && historyList.value.length >= parsed.total)) {
+      historyFinished.value = true
+    } else {
+      historyPage.value += 1
+    }
+  } catch (e: any) {
+    if (reset) {
+      historyList.value = []
+      historyTotal.value = 0
+    }
+    ElMessage.error(e?.message || e?.msg || '历史记录加载失败')
+  } finally {
+    historyLoading.value = false
+    historyLoadingMore.value = false
+  }
+}
+
+function onHistoryScroll(e: Event) {
+  const el = e.target as HTMLElement
+  if (!el) return
+  if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120) {
+    loadHistory(false)
+  }
+}
+
+function openHistory() {
+  historyVisible.value = true
+}
+
+watch(() => currentGoods.value?.hero.goods_art_no, (artNo) => {
+  if (artNo) ensureGoodsAngles(artNo)
+})
+
+watch([logoPath, goodsList, activeGoodsId], () => { saveDraft() }, { deep: true })
+
+async function loadTemplates() {
+  templatesLoading.value = true
+  try {
+    templates.value = await fetchPosterTemplates(templateKeywords.value)
+    if (!templates.value.length) ElMessage.warning('暂无可用湛江场景图样式')
+  } catch (e: any) {
+    templates.value = []
+    ElMessage.error(e?.message || e?.msg || '湛江场景图样式加载失败')
+  } finally {
+    templatesLoading.value = false
+  }
+}
+
+function onTemplateKeywordsInput() {
+  if (keywordsTimer) clearTimeout(keywordsTimer)
+  keywordsTimer = setTimeout(() => { loadTemplates() }, 400)
+}
+
+onMounted(async () => {
+  loadDraft()
+  await Promise.all([loadLogoList(true), loadTemplates()])
+})
+
+onBeforeUnmount(() => {
+  stopTimers()
+  hideTplPreview()
+  if (keywordsTimer) clearTimeout(keywordsTimer)
+  setPosterWindowClosable(true)
+  clientStore.ipc.removeAllListeners(icpList.utils.openImage)
+})
+</script>
+
+<style scoped lang="scss">
+.poster-page {
+  height: calc(100vh - 50px);
+  display: flex;
+  flex-direction: column;
+  background:
+    radial-gradient(1200px 420px at 8% -10%, rgba(41, 87, 255, 0.08), transparent 55%),
+    #f4f6fb;
+  overflow: hidden;
+}
+.poster-toolbar {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 14px 24px;
+  background: rgba(255, 255, 255, 0.92);
+  border-bottom: 1px solid #e8edf5;
+  backdrop-filter: blur(8px);
+}
+.logo-box {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 200px;
+}
+.logo-box__label {
+  font-size: 13px;
+  color: #666;
+  font-weight: 600;
+}
+.logo-card {
+  width: 48px;
+  height: 48px;
+  border: 1px dashed #c9d3e8;
+  border-radius: 12px;
+  overflow: hidden;
+  cursor: pointer;
+  background: #f7f9ff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: border-color .15s, box-shadow .15s;
+}
+.logo-card:hover { border-color: #2957ff; }
+.logo-card.active { border-style: solid; border-color: #2957ff; box-shadow: 0 0 0 3px rgba(41,87,255,.12); }
+.logo-card__img { width: 100%; height: 100%; object-fit: contain; }
+.logo-card__empty { font-size: 12px; color: #999; }
+.logo-list { display: flex; gap: 6px; }
+.logo-list__item {
+  width: 36px;
+  height: 36px;
+  border: 1px solid #eee;
+  border-radius: 4px;
+  overflow: hidden;
+  cursor: pointer;
+}
+.logo-list__item.active { border-color: #2957ff; }
+.logo-list__item img { width: 100%; height: 100%; object-fit: contain; }
+.toolbar-actions { flex: 1; display: flex; gap: 8px; align-items: center; }
+.gen-warn-icon {
+  display: inline-flex;
+  align-items: center;
+  font-size: 18px;
+  color: #e6a23c;
+  cursor: default;
+  outline: none;
+}
+.poster-main { flex: 1; display: flex; min-height: 0; }
+.list-pane {
+  flex: 1;
+  min-width: 0;
+  width: 100%;
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 22px 24px 28px;
+  display: flex;
+  flex-direction: column;
+}
+.list-pane__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+.list-pane__actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+.list-pane__title {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  font-size: 20px;
+  font-weight: 700;
+  color: #111827;
+  letter-spacing: -0.02em;
+}
+.list-pane__sub {
+  font-size: 13px;
+  color: #6b7280;
+  font-weight: 400;
+}
+.list-empty {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.empty-card {
+  width: 360px;
+  padding: 40px 28px;
+  text-align: center;
+  background: #fff;
+  border: 1px dashed #c9d4ea;
+  border-radius: 20px;
+  cursor: pointer;
+  transition: border-color .15s, box-shadow .15s, transform .15s;
+}
+.empty-card:hover {
+  border-color: #2957ff;
+  box-shadow: 0 12px 32px rgba(41, 87, 255, 0.12);
+  transform: translateY(-2px);
+}
+.empty-card__plus {
+  width: 52px;
+  height: 52px;
+  margin: 0 auto 12px;
+  border-radius: 16px;
+  background: #eef2ff;
+  color: #2957ff;
+  font-size: 32px;
+  line-height: 48px;
+  font-weight: 300;
+}
+.empty-card__title { font-size: 16px; font-weight: 700; color: #111827; }
+.empty-card__desc { margin-top: 6px; font-size: 13px; color: #6b7280; }
+.goods-grid {
+  flex: 1;
+  overflow: auto;
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 16px;
+  align-content: start;
+}
+.goods-card {
+  background: #fff;
+  border: 1px solid #e8edf5;
+  border-radius: 16px;
+  overflow: hidden;
+  cursor: pointer;
+  box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
+  transition: border-color .15s, box-shadow .15s, transform .15s;
+}
+.goods-card:hover {
+  border-color: #c5d0ff;
+  box-shadow: 0 12px 28px rgba(41, 87, 255, 0.12);
+  transform: translateY(-2px);
+}
+.goods-card__cover {
+  height: 150px;
+  background: #eef1f6;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #9aa3b2;
+  font-size: 13px;
+}
+.goods-card__cover .el-image { width: 100%; height: 100%; }
+.goods-card__body { padding: 12px 14px 14px; text-align: left; }
+.goods-card__no { font-size: 16px; font-weight: 700; color: #111827; text-align: left; }
+.goods-card__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+.pill {
+  display: inline-flex;
+  align-items: center;
+  height: 22px;
+  padding: 0 8px;
+  border-radius: 999px;
+  background: #f3f4f6;
+  color: #6b7280;
+  font-size: 12px;
+}
+.pill.ok { background: #eef2ff; color: #2957ff; }
+.goods-card__ops { display: flex; gap: 8px; margin-top: 12px; }
+.goods-card--add {
+  min-height: 260px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  border: 1px dashed #c9d4ea;
+  color: #2957ff;
+  background: rgba(255,255,255,.7);
+  box-shadow: none;
+}
+.goods-card--add:hover { background: #eef2ff; border-color: #2957ff; }
+.gen-btn { height: 40px; padding: 0 22px; border-radius: 10px; font-weight: 600; }
+.tpl-hover-preview {
+  position: fixed;
+  z-index: 4000;
+  width: 200px;
+  height: 200px;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+  overflow: hidden;
+  pointer-events: none;
+}
+.tpl-hover-preview img {
+  width: 200px;
+  height: 200px;
+  object-fit: contain;
+  display: block;
+}
+.is-empty { color: #c0c4cc; }
+.edit-layout {
+  display: grid;
+  grid-template-columns: 340px 1fr;
+  gap: 16px;
+  height: 68vh;
+  min-height: 0;
+}
+.edit-col {
+  min-width: 0;
+  min-height: 0;
+  height: 100%;
+  overflow-y: auto;
+  padding-right: 6px;
+}
+.edit-col--styles {
+  background: #f7f8fc;
+  border-radius: 14px;
+  padding: 14px;
+}
+.style-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 10px;
+}
+.style-item {
+  border: 1px solid #e6ebf4;
+  border-radius: 12px;
+  overflow: hidden;
+  cursor: pointer;
+  background: #fff;
+  transition: border-color .15s, transform .15s;
+}
+.style-item:hover { transform: translateY(-1px); }
+.style-item.on { border-color: #2957ff; box-shadow: 0 0 0 2px rgba(41,87,255,.18); }
+.style-item__img {
+  width: 100%;
+  height: 88px;
+  display: block;
+}
+.style-item__name {
+  font-size: 12px;
+  color: #374151;
+  padding: 6px 8px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.locked-no {
+  margin-left: auto;
+  height: 26px;
+  line-height: 26px;
+  padding: 0 10px;
+  border-radius: 999px;
+  background: #eef2ff;
+  color: #2957ff;
+  font-size: 12px;
+  font-weight: 700;
+}
+.artno-short { width: 150px; flex: 0 0 150px; }
+.color-input { width: 96px; flex: 0 0 96px; }
+.slot-top {
+  display: flex;
+  flex-wrap: nowrap;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
+  text-align: left;
+}
+.slot-top__label {
+  flex: 0 0 auto;
+  font-size: 12px;
+  color: #666;
+  white-space: nowrap;
+}
+.shot-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.shot {
+  position: relative;
+  width: 72px;
+  height: 72px;
+  border-radius: 8px;
+  overflow: hidden;
+  cursor: pointer;
+  background: #eef1f6;
+  flex: 0 0 72px;
+}
+.shot--sm { width: 64px; height: 64px; flex-basis: 64px; }
+.shot.on { box-shadow: 0 0 0 2px #2957ff; }
+.shot.disabled { cursor: not-allowed; opacity: .4; }
+.shot__img, .shot__empty {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #bbb;
+  font-size: 12px;
+}
+.shot__cap {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 12px 6px 4px;
+  background: linear-gradient(transparent, rgba(0, 0, 0, 0.55));
+  color: #fff;
+  font-size: 11px;
+  text-align: left;
+  line-height: 1.2;
+}
+.add-dialog { padding: 4px 0 8px; }
+.add-dialog__picked { margin-top: 10px; font-size: 13px; color: #2957ff; }
+.block { background: #f7f8fc; border-radius: 14px; padding: 14px; margin-bottom: 12px; }
+.block-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.block-title__bar { width: 3px; height: 14px; background: #2957ff; border-radius: 2px; }
+.block-title__sub { font-size: 12px; color: #999; font-weight: 400; }
+.field { margin-bottom: 10px; text-align: left; }
+.field__label { font-size: 12px; color: #666; margin-bottom: 6px; text-align: left; }
+.field-row { display: flex; gap: 8px; align-items: flex-end; }
+.flex-item { flex: 1; min-width: 0; }
+.color-field { width: 110px; flex-shrink: 0; }
+.add-color-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.slot-card {
+  border: 1px solid #e8edf5;
+  border-radius: 12px;
+  padding: 12px;
+  margin-bottom: 10px;
+  background: #fff;
+  text-align: left;
+}
+.slot-card__head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  font-size: 13px;
+}
+.edit-form { max-height: 68vh; overflow: auto; padding-right: 4px; }
+:deep(.edit-goods-dialog .el-dialog) { border-radius: 16px; }
+:deep(.edit-goods-dialog .el-dialog__body) {
+  padding-top: 8px;
+  overflow: hidden;
+}
+.hint { font-size: 12px; color: #999; }
+.angle-list { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
+.angle-item {
+  width: 76px;
+  flex: 0 0 76px;
+  border: 1px solid #e6ebf4;
+  border-radius: 10px;
+  overflow: hidden;
+  cursor: pointer;
+  background: #fff;
+}
+.angle-item.active { border-color: #2957ff; box-shadow: 0 0 0 2px rgba(41,87,255,.16); }
+.angle-item.disabled { cursor: not-allowed; opacity: .5; }
+.angle-item__img, .angle-item__empty {
+  width: 100%;
+  height: 52px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #bbb;
+  font-size: 12px;
+  background: #f5f5f5;
+}
+.angle-item__name { font-size: 11px; text-align: center; padding: 3px; color: #666; }
+.slot-card { border: 1px solid #eee; border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fff; }
+.slot-card__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; }
+.slot-card__body { display: flex; gap: 10px; }
+.slot-thumb {
+  width: 56px;
+  height: 56px;
+  flex-shrink: 0;
+  border-radius: 6px;
+  background: #f5f6f7;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #bbb;
+  font-size: 11px;
+  overflow: hidden;
+}
+.slot-thumb__img { width: 100%; height: 100%; }
+.slot-card__fields { flex: 1; min-width: 0; }
+.info-row {
+  margin-bottom: 10px;
+}
+.info-row__fields {
+  display: flex;
+  gap: 8px;
+  width: 100%;
+  align-items: center;
+}
+.info-row__label {
+  width: 92px;
+  flex-shrink: 0;
+  font-size: 13px;
+  color: #666;
+  line-height: 32px;
+}
+.info-row__value {
+  flex: 1;
+  min-width: 0;
+}
+.pg-fail {
+  padding: 36px 20px 20px;
+  text-align: center;
+}
+.pg-fail__title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 8px;
+}
+.pg-fail__desc {
+  font-size: 14px;
+  color: #f56c6c;
+  line-height: 1.6;
+  word-break: break-all;
+}
+.pg-fail__hint {
+  margin-top: 10px;
+  font-size: 12px;
+  color: #999;
+}
+.pg-submit {
+  padding: 48px 0;
+  text-align: center;
+  color: #666;
+  font-size: 14px;
+}
+.pg-head { margin-bottom: 16px; }
+.pg-head__meta {
+  display: flex;
+  justify-content: space-between;
+  font-size: 13px;
+  color: #666;
+  margin-bottom: 8px;
+}
+.pg-head__count { color: #2957ff; font-weight: 600; }
+.pg-list { max-height: 420px; overflow: auto; }
+.pg-card {
+  display: flex;
+  gap: 12px;
+  padding: 12px;
+  background: #f7f8fa;
+  border-radius: 10px;
+  margin-bottom: 10px;
+}
+.pg-card__img {
+  width: 72px;
+  height: 72px;
+  border-radius: 8px;
+  background: #fff;
+  flex-shrink: 0;
+}
+.pg-card__img.is-wait {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #999;
+  font-size: 12px;
+  background: #eef1f6;
+}
+.pg-card__body { flex: 1; min-width: 0; }
+.pg-card__top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  gap: 8px;
+}
+.pg-card__name { font-size: 14px; color: #333; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pg-card__status { font-size: 12px; flex-shrink: 0; }
+.pg-card__status.is-wait { color: #409eff; }
+.pg-card__status.is-done { color: #67c23a; }
+.pg-card__status.is-fail { color: #f56c6c; }
+.pg-card__foot {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 6px;
+  min-height: 22px;
+}
+.pg-card__time { font-size: 12px; color: #999; font-variant-numeric: tabular-nums; }
+:deep(.history-dialog) {
+  height: 82vh;
+  margin-top: 6vh !important;
+  display: flex;
+  flex-direction: column;
+}
+:deep(.history-dialog .el-dialog__header) { flex-shrink: 0; }
+:deep(.history-dialog .el-dialog__body) {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+  padding: 8px 20px 16px;
+  display: flex;
+  flex-direction: column;
+}
+.his-wrap {
+  flex: 1;
+  min-height: 0;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.his-grid {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  overflow-x: hidden;
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  grid-auto-rows: max-content;
+  align-content: start;
+  align-items: start;
+  gap: 14px;
+  padding-right: 6px;
+  padding-bottom: 12px;
+}
+.his-card {
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  border: 1px solid #e8edf5;
+  border-radius: 12px;
+  overflow: hidden;
+}
+.his-card__pic {
+  width: 100%;
+  aspect-ratio: 3 / 4;
+  background: #f4f6fa;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+.his-card__img,
+.his-card__pic :deep(.el-image),
+.his-card__pic :deep(img) {
+  width: 100%;
+  height: 100%;
+  display: block;
+  object-fit: contain;
+}
+.his-card__meta {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 8px;
+  height: 40px;
+  padding: 0 10px;
+  font-size: 12px;
+  color: #666;
+  background: #fff;
+  flex-shrink: 0;
+  box-sizing: border-box;
+}
+.his-card__time {
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: left;
+}
+.his-more {
+  grid-column: 1 / -1;
+  text-align: center;
+  padding: 12px 0 4px;
+  font-size: 12px;
+  color: #999;
+}
+:deep(.is-current) { background: #f3f6ff !important; }
+</style>

+ 17 - 16
public/dist/index.html

@@ -1,16 +1,17 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="./vite.svg" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>智惠映AI自动拍照机</title>
-    <script type="module" crossorigin src="./assets/index-BROJaE7c.js"></script>
-    <link rel="stylesheet" crossorigin href="./assets/index-B8MPi9a0.css">
-  </head>
-  <body>
-    <div id="app"></div>
-<!--    <script  src="/src/views/components/PictureEditor/js/fabric.js"></script>-->
-    <script src="./js/fabric.js"></script>

-  </body>
-</html>
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="./vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>智惠映AI自动拍照机</title>
+    <script type="module" crossorigin src="./assets/index-Dnby0yP_.js"></script>
+    <link rel="stylesheet" crossorigin href="./assets/index-CUtW5LTp.css">
+  </head>
+  <body>
+    <div id="app"></div>
+<!--    <script  src="/src/views/components/PictureEditor/js/fabric.js"></script>-->
+    <script src="./js/fabric.js"></script>
+
+  </body>
+</html>