Sfoglia il codice sorgente

feat(scene-set): 新增卖点套图功能模块

- 添加 /scene_set/prepare、/scene_set/run、/scene_set/save 后端接口
- 新增 SceneSetPrepare、SceneSetRun、SceneSetSave 请求模型
- 实现 scene_set 服务层,包含抠图读取、场景任务创建、结果保存逻辑
- 添加 Electron 层 sceneSetPrepare/Run/Save/Cutout API 及控制器方法
- 前端注册卖点套图路由、菜单入口及 IPC 通道
- 新增 productScene.json 品类配置(音箱类槽位、场景模板)
- 更新 cx_Freeze 打包配置,增加 service.scene_set 等模块包含项
panqiuyao 20 ore fa
parent
commit
25e56b7996

+ 180 - 0
docs/卖点套图实现说明.md

@@ -0,0 +1,180 @@
+# 卖点套图:实现说明
+
+给接下来写代码的 AI。先读完「不要做」和「照着谁做」,再改代码。
+
+## 打开哪个项目
+
+主项目是拍照机客户端:
+
+`D:\git\hlm\aigc\CameraMachine`
+
+在这个仓库里做页面、菜单、Electron 转发和本机 Python。用一条新的 git 分支,不要直接在主干上改。
+
+服务端是另一个仓库:
+
+`D:\git\hlm\backend\hlm_backend`
+
+只有做「后端契约」里的接口、表和 RunningHub 任务时才打开它。客户端不要把 RunningHub 的 key 写进代码或配置。
+
+## 这次做什么
+
+做一个独立功能,名字叫「卖点套图」。
+
+操作员从「高级生成」打开自己的窗口,选货号,用已经拍好的抠图,出一套电商场景图。一套里可以是「指定场景」,也可以是「按卖点」。图生图只出场景,文字用字体后叠。三视图和细节图用实拍抠图本地排。
+
+## 不要做
+
+- 不要改「主图与详情生成」。下面这些文件和函数保持原样,不要加分支、不要加参数、不要加菜单项:
+  - `frontend/src/views/Photography/detail.vue`
+  - `frontend/src/views/Photography/seniorDetail.vue`
+  - `frontend/src/views/Photography/components/SceneCard.vue`
+  - `python/api.py` 里的 `/handle_detail` 和 `process_handle_detail`
+  - `python/service` 里现有的场景图、详情页、模特图步骤
+- 不要把结果写回详情页用的 `场景图.jpg`,也不要改 `goods_no_dict`。
+- 不要重做抠图。货号目录里已有 `原始图_已抠图` 就裁边后用。不要对白底图做泛洪去底,黑色织布的网孔会被抠穿。
+- 不要让图像模型画中文。模型提示词里写明不要文字。
+- 不要编造画面上看不见、资料里也没有的参数。续航、防水、功率、厘米都属于这类。没有实测宽高深和重量,就不要出尺寸页。
+- 不要在一套图里给每张图随机或轮换文案位置。
+- 不要交付 HTML 页面。排版可以用 HTML,最终只留 PNG。
+
+## 照着谁做
+
+独立窗口已经有一个例子:湛江场景图。卖点套图照这个接法,不要照详情页。
+
+| 用途 | 现有文件 |
+| --- | --- |
+| 高级生成菜单 | `frontend/src/utils/menus/generate.ts` 里的 `openPosterGenerate` |
+| 路由 | `frontend/src/router/index.ts` 的 `/photography/poster` |
+| 页面 | `frontend/src/views/Photography/poster/index.vue` |
+| 前端接口名 | `frontend/src/utils/ipc.ts` 的 `generate.generatePoster` |
+| Electron 控制器 | `electron/controller/generate.js` 的 `generatePoster` |
+| Electron 请求 | `electron/api/generate.js`,它用本机 `post()` 打 Python |
+
+湛江场景图的窗口参数:宽 1400,高 900,`frame: true`,自己的 `id`。卖点套图用另一个 `id`,例如 `sceneSet`,避免和 `posterGenerate`、`PhotographyDetail` 抢同一个窗口。
+
+## 客户端要新增的入口
+
+1. `frontend/src/router/index.ts` 增加路由 `/photography/scene-set`,页面 `frontend/src/views/Photography/sceneSet/index.vue`。
+2. `frontend/src/utils/menus/generate.ts` 的「高级生成」子菜单增加「卖点套图」。点击后用 `getRouterUrl('#/photography/scene-set')` 开独立窗口。
+3. `frontend/src/views/Photography/mixin/usePhotography.ts` 里,高级生成点击「历史记录」和「湛江场景图」时不会强行带拍摄图。卖点套图同样直接打开自己的窗口,不从拍摄页塞进详情页。
+4. `frontend/src/utils/ipc.ts`、`electron/controller/generate.js`、`electron/api/generate.js` 增加本功能自己的通道。Python 用新路径,例如 `/scene_set/prepare` 和 `/scene_set/run`。不要复用 `/handle_detail`。
+5. Python 新文件 `python/service/scene_set.py`,在 `python/api.py` 里只注册新路由。
+
+## 页面怎么用
+
+一个窗口里完成,不经过详情页。
+
+1. 选择已拍摄的货号。货号来自现有拍照记录,沿用取记录的接口即可。
+2. 读这个货号目录下的 `原始图_已抠图`。按品类配置认出机位,页面上让操作员看到每一张对应哪个机位,能改旋转和「这一张不用于场景」。
+3. 出图方式二选一:
+   - 指定场景:勾选场景模板。每个货号按自己的档位出勾中的场景。
+   - 按卖点:资料里写了卖点就照写的出;资料没写就从资料其余字段整理,每条必须有原文依据;完全没有资料就只从画面可见的特征整理。看不见的参数不要写。
+4. 文案位置:这一套只选一个,选项是左上、右上、左下、右下、中间。选定后,这一套所有带字的图都用这个位置。
+5. 可选资料:这个窗口自己上传 Excel,或手填卖点。没有资料也可以生成。不要去读详情页的「商品资料对接」状态。
+6. 点生成后看进度。单张失败不影响同套其他图。
+7. 结果放在该货号自己的新目录 `场景套图/`。里面是场景 JPG、叠字后的详情 PNG、`卖点.txt`。已有这个目录就跳过,要重出先删目录。
+
+## 本机处理顺序
+
+`python/service/scene_set.py` 按这个顺序做。
+
+1. 找 `原始图_已抠图` 里的 PNG。找到了就不要再抠。
+2. 按品类配置认机位。每一张配置:序号、机位名、是否用于长场景、旋转角度、三视图或细节页用不用。
+   - 横放手提的起始顺序:端面(弃用)、斜前、斜侧、顶面(旋转 180)、背面、正面。顶面原图提手在下、字是倒的,所以转 180。
+   - 落地派对箱可以是正面、45 度、背面。没有顶面就不要用背面冒充顶面。
+3. 裁掉四周透明边。alpha 大于 16 的外框,外扩 12 像素。长边缩到 2048。上传用真正的 PNG。不要用会把文件写成 jpeg 的上传方法。
+4. 做灰底板。画布长边 2048,2:3 大约是 1366×2048,底色 RGB(186,186,186)。缩放 `scale = min(height_ratio * H / h, max_width_ratio * W / w)`,水平居中,`y = H - bottom_ratio * H - 商品高`,JPEG 质量 90。
+   - 落地大箱:height_ratio 0.43 到 0.45,bottom_ratio 0.01 到 0.03。
+   - 横放、机身扁的手提:height_ratio 0.30,bottom_ratio 0.10,max_width_ratio 0.66。这是实拍校准过的,不要套落地箱的比例。
+5. 把底板交给 RunningHub 图生图。接口 `/openapi/v2/rhart-image-n-g31-flash/image-to-image`,参数 `imageUrls`、`prompt`、`aspectRatio: "2:3"`、`resolution: "2k"`。失败再试官方路径 `/openapi/v2/rhart-image-n-g31-flash-official/image-to-image`。返回的 JPEG 原样保存,不要再压成别的格式。2k 实测大约 1696×2528。
+6. 场景 JPG 下载后,用字体把营销文案叠成详情 PNG。逻辑画布 750×1200,2 倍输出约 1500×2400。字体用微软雅黑。可以用本机 Chrome 无头截图,最终只留 PNG。
+7. 三视图和细节页不上传图生图。用实拍抠图本地排。
+8. 写 `卖点.txt`:卖点来源(资料填写、资料整理、画面可见)、每条依据、每张图对应哪条卖点。没有依据的参数不写。
+
+提示词必须带上这些约束:画面里只能有一台;不要倒影、镜面、湿地面反光、上下叠放;没有的部件不要画;不要任何文字。横放箱写明保持现在的大小,不要画成落地大箱,也不要缩成迷你音箱。沙滩写明沙子是干的。人可以在远处,手不要碰到商品。
+
+模型会把商品挪位:中心大约从 50% 挪到 36%,并上移约 10%,大小基本不变。这一期只出图,不做自动贴回。
+
+## 文案位置
+
+字段名 `copy_anchor`。一套模板、一次生成只有一个值。
+
+可选值:`top-left`、`top-right`、`bottom-left`、`bottom-right`、`center`。界面对应左上、右上、左下、右下、中间。
+
+模板设成左下,这一套的场景页、三视图、细节页就全部左下。不要按张轮换,不要每张随机。不同模板可以设不同位置,所以代码里不能只实现左上。
+
+遮罩跟着这个位置:上角从上往下压暗,下角从下往上压暗,中间只在文字块后面加一块半透明底。
+
+标题是场景里的一句话,例如「沙地上 / 先把歌打开」。不要出现「卖点:」。没有依据的能力不要写进说明。续航、蓝牙版本这类数字,只有资料里有原文时才出现,并且只放在尺寸或参数页。
+
+## 卖点和场景怎么配
+
+优先级:
+
+1. 资料里填了卖点:照写,不增不删不改写。先用关键词归类,归不了才交给 AI,AI 只做归类。
+2. 没填卖点,但有其他资料:AI 整理,每条带资料原文。原文对不上的丢掉。
+3. 完全没有资料:只保留画面看得见的特征,依据写成「画面可见」。不要补续航、防水、功率、厘米。
+4. 有资料但什么都整理不出:用该档位的默认场景,并在 `卖点.txt` 里注明。
+
+按卖点配场景:每个场景模板有 `expresses`(它能表现哪些卖点)和 `requires`(必须具备的卖点)。没有防水依据就不要出泳池,沙滩文案里也不要写防水。每个场景只用一次。场景图一个货号默认 6 张,最多 8 张。三视图和细节页另计,不占这 8 张,也不按场景图单价扣费。
+
+指定场景:操作员勾选的场景,每个货号按自己的档位取同名模板。这个档位没有的就跳过并在进度里说明。
+
+档位:
+
+- S:小于 25cm,便携,放桌面、野餐垫或毛巾。
+- M:25 到 55cm,手提。横放扁箱用上面的 0.30 / 0.10 / 0.66。较高的 M 档另测,不要和扁箱共用。
+- L:55cm 以上,落地,只能站在地面上。
+- 没有高度时:页面上可选跳过该货号,或按操作员选的默认档出图。不要编一个厘米数。
+
+三视图用正面、侧面、顶面。浅底大约 `#f3f0eb`,三张白卡片竖排,产品尽量大,标签贴在图下。不要用三列高黑底。背面和正面轮廓太像,不要把背面标成侧面。
+
+细节页从实拍按相对坐标裁,例如提手、端面、顶面按键。按键和标志以实拍为准。
+
+## 后端契约
+
+仓库:`D:\git\hlm\backend\hlm_backend`。这是独立任务,不要接到现有详情页回调的通用退款上。
+
+拍照机接口放在 `routes/ai_image/ai_image.php` 的 `camera_machine` 分组,需要登录。
+
+- `GET product_scene/categories`:本企业可用品类、档位、机位、场景列表、文案位置可选项、资料模板、单价。
+- `POST product_scene/create_job`:入参 category_id、goods_art_no、basis(`selling_point` 或 `scene`)、scene_tags、materials、cutouts、copy_anchor、options。返回 job_id。
+- `POST product_scene/jobs_status`:一次查多个任务。返回状态、卖点来源、每张图的状态和地址。
+
+`copy_anchor` 记在任务上,整单一个值。每张图的记录不要再各自选位置。
+
+RunningHub 账务继续走 `aigc_user_generates`,新用 category 90。在 `app/Services/RunningHubService.php` 的 `updateResult` 里,category 90 单独交给卖点套图服务。不要走现有的通用退款:那条路径会退到账号 0,并把扣费场景写死。
+
+金币:
+
+- `product_scene_set` 按场景图张数计。三视图和细节页不计。
+- `product_scene_analyze` 按款计,没有资料、只看画面时也只算一次。
+- 按卖点时张数要整理完才知道,先按最多张数预扣,再退差额。指定场景按实际张数扣。失败按张退回这个任务的账号。自动重试不扣。
+- 不要复用 `userPermissionBatch`。它会因为 1 分钟内有任务就拒绝,批量会被挡住。
+
+提交参数保持:`resolution: "2k"`,比例默认 2:3。同时最多占用 3 个 RunningHub 并发,其余留给现有功能。
+
+管理端这一期只留品类和场景模板的接口,以及用样例抠图试一张的 `preset_test`。不要做后台页面。
+
+## 音箱第一套配置
+
+卖点标签先做:灯效、防水、续航、低音、滚轮、K 歌、便携、提手、蓝牙、户外、居家、派对。关键词写在品类配置里。
+
+场景模板:
+
+- L:黄昏营地、客厅地板、夜晚庭院、海边步道(需要提手和滚轮)、白天沙滩、沙地野餐远景。
+- M:营地木桌、客厅边柜、夜晚庭院长桌、公园长椅(需要提手)、白天沙滩、泳池边(需要防水)。
+- S:书桌、咖啡桌、野餐垫、沙滩毛巾、夜晚庭院木桌、泳池边(需要防水)。
+- 每个档位再留一条 custom 骨架,不出现在指定场景的勾选列表里。
+
+横放手提的实拍已经验证过:品牌、横放造型、顶部提手、橙色两端、黑色织布、顶面按键可以从画面确认。没有资料时就只用这些,不要写成防水或续航。
+
+## 验收
+
+- 从「高级生成」打开「卖点套图」,不打开「主图与详情生成」。`detail.vue` 和 `/handle_detail` 的行为与改动前一致。
+- 指定场景:勾 3 个场景,出一个货号的 3 张场景图,外加三视图和细节页。文案位置选右上之后,这一套全部在右上。
+- 按卖点、没有资料:`卖点.txt` 只有画面可见点。沙滩只有一台,干沙,下面没有第二台倒影。没有尺寸页。
+- 已有 `原始图_已抠图` 时,日志或进度里能看出没有再抠一次。
+- 结果在货号目录的 `场景套图/`,详情页原来的 `场景图.jpg` 不被覆盖。
+- 再点一次生成,已有目录的货号跳过,不扣金币。
+- 图生图结果里没有模型画上去的字。成图上的字是字体后叠的,没有「卖点:」。

+ 40 - 0
electron/api/generate.js

@@ -50,4 +50,44 @@ module.exports = {
       }
     })
   },
+
+  sceneSetPrepare(data){
+    return post({
+      url: '/scene_set/prepare',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
+
+  sceneSetRun(data){
+    return post({
+      url: '/scene_set/run',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
+
+  sceneSetSave(data){
+    return post({
+      url: '/scene_set/save',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
+
+  sceneSetCutout(data){
+    return post({
+      url: '/remove_background',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
 }

+ 49 - 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, generatePoster } = require('../api/generate');
+const { generatePhotoDetail, getLogoList, addLogo, deleteLogo, getGoodsImageJson, generatePoster, sceneSetPrepare, sceneSetRun, sceneSetSave, sceneSetCutout } = require('../api/generate');
 
 const errData = {
   msg :'请求失败,请联系管理员',
@@ -107,6 +107,54 @@ class GenerateController extends Controller {
     }
   }
 
+  async sceneSetPrepare(args) {
+    try {
+      const result = await sceneSetPrepare(args);
+      if (result.data) return result.data;
+      return errData;
+    } catch (error) {
+      Log.error('卖点套图准备失败:', error);
+      return errData;
+    }
+  }
+
+  async sceneSetRun(args) {
+    try {
+      const result = await sceneSetRun(args);
+      if (result.data) return result.data;
+      return errData;
+    } catch (error) {
+      Log.error('卖点套图生成失败:', error);
+      return errData;
+    }
+  }
+
+  async sceneSetSave(args) {
+    try {
+      const result = await sceneSetSave(args);
+      if (result.data) return result.data;
+      return errData;
+    } catch (error) {
+      Log.error('卖点套图保存失败:', error);
+      return errData;
+    }
+  }
+
+  async sceneSetCutout(args) {
+    try {
+      const result = await sceneSetCutout(args);
+      if (result.data) return result.data;
+      return errData;
+    } catch (error) {
+      Log.error('卖点套图抠图失败:', error);
+      const body = error && error.response && error.response.data;
+      if (body && (body.msg || body.message)) {
+        return { code: body.code || 1, msg: body.msg || body.message, data: null };
+      }
+      return errData;
+    }
+  }
+
 
 }
 

+ 19 - 0
frontend/src/apis/sceneSet.ts

@@ -0,0 +1,19 @@
+import { POST } from '@/utils/http'
+import productScene from '@/views/Photography/sceneSet/productScene.json'
+
+// demo 阶段品类、机位、场景写在本地 JSON,不请求 categories 接口。
+export function fetchSceneSetCategories() {
+  return Promise.resolve({
+    code: 0,
+    data: productScene,
+  })
+}
+
+export function fetchSceneSetJobs(jobIds: Array<number | string>) {
+  return POST('/api/ai_image/camera_machine/product_scene/jobs_status', {
+    job_ids: jobIds,
+  }, {
+    loading: false,
+    showErrorMessage: false,
+  })
+}

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

@@ -89,6 +89,14 @@ const routes: RouteRecordRaw[] = [
         }
     },
     {
+        path: "/photography/scene-set",
+        name: "PhotographySceneSet",
+        component: () => import("@/views/Photography/sceneSet/index.vue"),
+        meta: {
+            title: '卖点套图'
+        }
+    },
+    {
         path: "/remote_control",
         name: "RemoteControl",
         component: () => import("@/views/RemoteControl/index.vue"),

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

@@ -70,6 +70,10 @@ const icpList = {
         deleteLogo: 'controller.generate.deleteLogo',
         getGoodsImageJson: 'controller.generate.getGoodsImageJson',
         generatePoster: 'controller.generate.generatePoster',
+        sceneSetPrepare: 'controller.generate.sceneSetPrepare',
+        sceneSetRun: 'controller.generate.sceneSetRun',
+        sceneSetSave: 'controller.generate.sceneSetSave',
+        sceneSetCutout: 'controller.generate.sceneSetCutout',
     },
     ota:{
         updateVersion: 'controller.ota.updateVersion'

+ 19 - 0
frontend/src/utils/menus/generate.ts

@@ -37,6 +37,12 @@ const generate =  {
                 },
             },
             {
+                name:'卖点套图',
+                click(){
+                    openSceneSet()
+                },
+            },
+            {
                 name:'历史记录',
                 click(configs){
                     openGaenrate('mine',configs)
@@ -87,6 +93,19 @@ export function openGaenrate(type,configs) {
     clientStore.ipc.send(icpList.utils.openMain, params)
 }
 
+export function openSceneSet() {
+    clientStore.ipc.removeAllListeners(icpList.utils.openMain)
+    const params = {
+        title: '卖点套图',
+        width: 1400,
+        height: 900,
+        frame: true,
+        id: 'sceneSet',
+        url: getRouterUrl('#/photography/scene-set')
+    }
+    clientStore.ipc.send(icpList.utils.openMain, params)
+}
+
 export function openPosterGenerate() {
     clientStore.ipc.removeAllListeners(icpList.utils.openMain)
     const params = {

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

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

+ 2590 - 0
frontend/src/views/Photography/sceneSet/index.vue

@@ -0,0 +1,2590 @@
+<template>
+  <BlueHeaderBar />
+  <div class="ss-page">
+    <div class="ss-top">
+      <div class="ss-top__title">
+        <b>卖点套图</b>
+        <span>用已经抠好的图,一次出一整套带卖点文字的场景图</span>
+      </div>
+      <div class="ss-steps">
+        <template v-for="(item, index) in steps" :key="item.no">
+          <div
+            class="ss-step"
+            :class="{ 'is-active': step === item.no, 'is-done': step > item.no, 'is-clickable': step !== item.no && canEnter(item.no) }"
+            @click="goStep(item.no)"
+          >
+            <span class="ss-step__no">
+              <el-icon v-if="step > item.no"><Check /></el-icon>
+              <template v-else>{{ item.no }}</template>
+            </span>
+            <span class="ss-step__text">
+              <b>{{ item.title }}</b>
+              <small>{{ item.desc }}</small>
+            </span>
+          </div>
+          <span v-if="index < steps.length - 1" class="ss-step__line" :class="{ 'is-done': step > item.no }" />
+        </template>
+      </div>
+    </div>
+
+    <div ref="mainRef" class="ss-main">
+      <div class="ss-inner">
+        <div v-show="step === 1" class="ss-stack">
+          <section class="ss-card">
+            <div class="ss-card__head">
+              <div class="ss-card__title">选择货号</div>
+              <div class="ss-card__desc">从拍摄记录里选。已经抠好的图会直接用;没有的话可以在这里重新抠图。</div>
+            </div>
+            <div v-if="categoryState === 'loading'" class="ss-muted ss-inline">
+              <el-icon class="is-loading"><Loading /></el-icon>正在加载品类…
+            </div>
+            <el-alert v-else-if="categoryState === 'error'" type="error" :closable="false" show-icon title="品类没有加载出来">
+              <div class="alert-body">
+                <div>{{ categoryError }}</div>
+                <div class="alert-actions"><el-button size="small" @click="loadCategories">重试</el-button></div>
+              </div>
+            </el-alert>
+            <el-alert
+              v-else-if="!categories.length"
+              type="warning"
+              :closable="false"
+              show-icon
+              title="暂时没有可用的品类"
+              description="服务端还没有给这个账号配置卖点套图品类,请联系管理员。"
+            />
+            <template v-else>
+              <div class="goods-row">
+                <div class="goods-row__search">
+                  <GoodsArtNoSearch v-model="goodsArtNo" placeholder="输入货号搜索,或点开从最近拍摄里选" @select="onGoods" />
+                </div>
+                <el-select v-if="categories.length > 1" v-model="categoryId" class="goods-row__category" @change="onCategory">
+                  <el-option v-for="item in categories" :key="item.id" :label="`品类:${item.name}`" :value="item.id" />
+                </el-select>
+                <span v-else class="ss-pill">品类:{{ currentCategory?.name }}</span>
+                <span v-if="goodsMeta" class="ss-muted">{{ goodsMeta }}</span>
+              </div>
+              <template v-if="goodsArtNo">
+                <div v-if="cutting" class="ss-muted ss-inline">
+                  <el-icon class="is-loading"><Loading /></el-icon>正在用这个货号的拍摄图抠图,抠完会自动读进来…
+                </div>
+                <div v-else-if="preparing" class="ss-muted ss-inline">
+                  <el-icon class="is-loading"><Loading /></el-icon>正在读取这个货号的抠图…
+                </div>
+                <el-alert
+                  v-else-if="!imagePath"
+                  type="warning"
+                  :closable="false"
+                  show-icon
+                  title="这个货号没有找到拍摄图"
+                  description="请换一个货号,或者先完成拍摄。"
+                />
+                <el-alert v-else-if="prepareError" type="warning" :closable="false" show-icon :title="prepareErrorView.title">
+                  <div class="alert-body">
+                    <div>{{ prepareErrorView.desc }}</div>
+                    <div v-if="prepareErrorView.desc !== prepareError" class="alert-raw">原因:{{ prepareError }}</div>
+                    <div class="alert-actions">
+                      <el-button v-if="missingCutout" size="small" type="primary" :disabled="cutting" @click="recut">重新抠图</el-button>
+                      <el-button size="small" @click="loadCutouts">重新读取</el-button>
+                    </div>
+                  </div>
+                </el-alert>
+                <el-alert v-else-if="outputExists" type="warning" :closable="false" show-icon title="这个货号已经做过卖点套图">
+                  <div class="alert-body">
+                    <div>结果在货号文件夹的「场景套图」里。要重新出图,先把这个文件夹删掉或改名,再点「重新检查」。</div>
+                    <div class="alert-actions">
+                      <el-button size="small" @click="openDir(outputDir)">打开结果文件夹</el-button>
+                      <el-button size="small" @click="loadCutouts">重新检查</el-button>
+                    </div>
+                  </div>
+                </el-alert>
+                <div v-else-if="cutouts.length" class="goods-ok">
+                  <el-icon><CircleCheckFilled /></el-icon>读到 {{ cutouts.length }} 张抠图,下面确认一下商品大小和机位
+                </div>
+              </template>
+            </template>
+          </section>
+
+          <section v-if="currentCategory" class="ss-card">
+            <div class="ss-card__head">
+              <div class="ss-card__title">商品有多大</div>
+              <div class="ss-card__desc">决定能用哪些场景,以及商品在画面里放多大。拿不准就选中号。</div>
+            </div>
+            <div class="tier-grid">
+              <div
+                v-for="item in tierOptions"
+                :key="item.value"
+                class="tier-card"
+                :class="{ 'is-on': tier === item.value }"
+                @click="pickTier(item.value)"
+              >
+                <div class="tier-card__top">
+                  <span class="tier-card__badge">{{ item.value }}</span>
+                  <span class="tier-card__name">{{ item.name }}</span>
+                  <span class="tier-card__range">{{ item.range }}</span>
+                </div>
+                <div class="tier-card__desc">{{ item.desc }}</div>
+                <div class="tier-card__scenes">场景:{{ sceneNames(item.value) || '暂无' }}</div>
+              </div>
+            </div>
+            <div class="height-row">
+              <span>知道实测高度可以填上:</span>
+              <el-input
+                :model-value="heightCm"
+                class="height-row__input"
+                placeholder="可不填"
+                clearable
+                @update:model-value="onHeightInput"
+              >
+                <template #append>cm</template>
+              </el-input>
+              <span class="ss-muted">
+                {{ heightValue ? `已按 ${heightValue}cm 选了${tierInfo.name}` : '填了会自动选大小,也能用在尺寸图上;不填就按上面选的出图,不会编高度' }}
+              </span>
+            </div>
+          </section>
+
+          <section v-if="cutouts.length && !prepareError && !outputExists" class="ss-card">
+            <div class="ss-card__head ss-card__head--row">
+              <div class="ss-card__grow">
+                <div class="ss-card__title">确认抠图</div>
+                <div class="ss-card__desc">已按拍摄顺序自动对应机位,一般不用改。哪张不对,点那张图修改。</div>
+              </div>
+              <div class="layout-switch">
+                <span>拍摄方式</span>
+                <el-radio-group v-model="layout" size="small" @change="onLayout">
+                  <el-radio-button v-for="item in layoutOptions" :key="item.value" :label="item.value">{{ item.label }}</el-radio-button>
+                </el-radio-group>
+              </div>
+            </div>
+            <div class="cutout-grid">
+              <div
+                v-for="item in cutouts"
+                :key="item.path"
+                class="cutout-card"
+                :class="{ 'is-editing': editingPath === item.path, 'is-off': isUnused(item) }"
+                :title="item.file"
+                @click="toggleEdit(item)"
+              >
+                <div class="cutout-card__img">
+                  <img :src="toFileUrl(item.path)" :style="{ transform: `rotate(${item.rotation || 0}deg)` }" alt="" />
+                  <span class="cutout-card__index">{{ item.index }}</span>
+                </div>
+                <div class="cutout-card__name">
+                  {{ item.name }}
+                  <em v-if="duplicateSlots.has(item.slot_id)">机位重复</em>
+                </div>
+                <div class="cutout-card__tags">
+                  <span v-for="tag in cutoutTags(item)" :key="tag.text" class="cut-tag" :class="`cut-tag--${tag.type}`">{{ tag.text }}</span>
+                </div>
+              </div>
+            </div>
+            <div v-if="editingCutout" class="cutout-edit">
+              <div class="cutout-edit__title">第 {{ editingCutout.index }} 张 · {{ editingCutout.file }}</div>
+              <div class="cutout-edit__row">
+                <span class="cutout-edit__label">机位</span>
+                <el-select v-model="editingCutout.slot_id" size="small" class="cutout-edit__select" @change="onSlot(editingCutout)">
+                  <el-option label="不使用这张" :value="0" />
+                  <el-option v-for="slot in layoutSlots" :key="slot.id" :label="slot.name" :value="slot.id" />
+                </el-select>
+                <span class="cutout-edit__sep" />
+                <span class="cutout-edit__label">方向</span>
+                <el-button size="small" :icon="RefreshLeft" @click="rotate(editingCutout, -90)">左转</el-button>
+                <el-button size="small" :icon="RefreshRight" @click="rotate(editingCutout, 90)">右转</el-button>
+                <span class="ss-muted">现在 {{ editingCutout.rotation || 0 }}°</span>
+                <span class="cutout-edit__sep" />
+                <span v-if="isPlate(editingCutout)" class="cut-tag cut-tag--plate">已是场景底图</span>
+                <el-button v-else size="small" type="primary" plain @click="setPlate(editingCutout)">用这张做场景底图</el-button>
+                <el-button size="small" type="text" class="cutout-edit__done" @click="editingPath = ''">完成</el-button>
+              </div>
+            </div>
+            <div class="cutout-legend">
+              <span><i class="cut-tag cut-tag--plate">场景底图</i>所有场景图都用这张生成</span>
+              <span><i class="cut-tag cut-tag--view">三视图</i><i class="cut-tag cut-tag--detail">细节图</i>本机用实拍排版,不扣金币</span>
+              <span><i class="cut-tag cut-tag--off">不使用</i>这套图里不用这张</span>
+            </div>
+            <el-alert
+              v-if="!plateCutout"
+              type="error"
+              :closable="false"
+              show-icon
+              title="还没有场景底图"
+              description="点一张角度好看的抠图,选「用这张做场景底图」。"
+            />
+            <div v-else-if="threeViewMissing.length" class="ss-muted">缺{{ threeViewMissing.join('、') }},这套不出三视图。</div>
+          </section>
+        </div>
+
+        <div v-show="step === 2" class="ss-stack">
+          <section class="ss-card">
+            <div class="ss-card__head">
+              <div class="ss-card__title">场景图怎么挑</div>
+              <div class="ss-card__desc">场景图由 AI 生成,按张扣金币;三视图、细节图在本机排版,不扣金币。</div>
+            </div>
+            <div class="basis-grid">
+              <div class="basis-card" :class="{ 'is-on': basis === 'selling_point' }" @click="basis = 'selling_point'">
+                <span class="basis-card__radio" />
+                <div>
+                  <div class="basis-card__title">按卖点自动挑<span class="ss-pill ss-pill--blue">推荐</span></div>
+                  <div class="basis-card__desc">根据下面写的卖点,从{{ tierInfo.name }}的场景里挑 {{ autoCountText }}。没写卖点也能出。</div>
+                </div>
+              </div>
+              <div class="basis-card" :class="{ 'is-on': basis === 'scene' }" @click="basis = 'scene'">
+                <span class="basis-card__radio" />
+                <div>
+                  <div class="basis-card__title">我自己选场景</div>
+                  <div class="basis-card__desc">想要哪几个场景就勾哪几个,每个场景出一张。</div>
+                </div>
+              </div>
+            </div>
+            <template v-if="basis === 'scene'">
+              <div v-if="tierScenes.length" class="scene-grid">
+                <div
+                  v-for="item in tierScenes"
+                  :key="item.tag"
+                  class="scene-card"
+                  :class="{ 'is-on': sceneTags.includes(item.tag) }"
+                  @click="toggleScene(item.tag)"
+                >
+                  <span class="scene-card__check"><el-icon><Check /></el-icon></span>
+                  <div class="scene-card__name">{{ item.name }}</div>
+                  <div v-if="item.title" class="scene-card__sub">图上标题:{{ item.title }}</div>
+                  <div v-if="item.expresses?.length" class="scene-card__sub">适合表达:{{ item.expresses.join('、') }}</div>
+                  <div v-if="item.requires?.length" class="scene-card__need">商品要有{{ item.requires.join('、') }}才适合</div>
+                </div>
+              </div>
+              <div v-else class="ss-muted">{{ tierInfo.name }}还没有配置场景,请换一个大小。</div>
+              <div v-if="tierScenes.length" class="ss-muted">已选 {{ sceneTags.length }} 个,出 {{ sceneTags.length }} 张场景图。</div>
+            </template>
+            <div v-else class="scene-pool">
+              <span class="ss-muted">{{ tierInfo.name }}可用场景:</span>
+              <span v-for="item in tierScenes" :key="item.tag" class="pool-chip">
+                {{ item.name }}<em v-if="item.requires?.length">(卖点写到{{ item.requires.join('、') }}才会用)</em>
+              </span>
+            </div>
+          </section>
+
+          <section class="ss-card">
+            <div class="ss-card__head">
+              <div class="ss-card__title">卖点文字<span class="ss-optional">可不填</span></div>
+              <div class="ss-card__desc">
+                一行写一条,会原样印在场景图上,不会改写。{{ basis === 'selling_point' ? '也用来决定挑哪些场景。' : '' }}
+              </div>
+            </div>
+            <el-input v-model="sellingPoints" type="textarea" :rows="5" resize="none" :placeholder="sellingPlaceholder" />
+            <div class="ss-muted">带数字的参数(比如续航 12 小时、40W、IPX6)不印在场景图上,会单独出一张参数图。</div>
+            <div v-if="!sellingLines.length && visibleFeatures.length" class="visible-box">
+              <div v-if="hasMaterials" class="ss-muted">没写卖点,会从「更多资料」里整理;资料里整理不出时用通用场景,不再用画面特征。</div>
+              <template v-else>
+                <div class="visible-box__head">
+                  <span>没写卖点时,用图上看得到的特征当卖点(看不到的参数不会写):</span>
+                  <el-button v-if="layout === 'handheld' && verifiedKeys.length" size="small" type="text" @click="fillVerified">
+                    勾选横放手提款已核对的 {{ verifiedKeys.length }} 项
+                  </el-button>
+                </div>
+                <div class="chip-row">
+                  <span
+                    v-for="item in visibleFeatures"
+                    :key="item.key"
+                    class="chip"
+                    :class="{ 'is-on': visibleKeys.includes(item.key) }"
+                    @click="toggleVisible(item.key)"
+                  >
+                    <el-icon v-if="visibleKeys.includes(item.key)"><Check /></el-icon>{{ item.label }}
+                  </span>
+                </div>
+              </template>
+            </div>
+            <el-collapse v-model="moreOpen" class="more">
+              <el-collapse-item name="more">
+                <template #title>
+                  <span class="more__title">更多资料</span>
+                  <span class="ss-muted">可选:尺寸、参数、资料 Excel</span>
+                </template>
+                <div v-if="sizeFields.length" class="more-group">
+                  <div class="more-group__title">尺寸<span class="ss-muted">宽度、深度、重量,加上第 1 步的高度都填了,会多出一张尺寸图</span></div>
+                  <div class="material-grid">
+                    <label v-for="key in sizeFields" :key="key" class="material-item">
+                      <span>{{ key }}</span>
+                      <el-input v-model="materialValues[key]" size="small" clearable :placeholder="fieldHints[key] || '可不填'" />
+                    </label>
+                    <div class="material-item">
+                      <span>高度</span>
+                      <span class="material-item__value">{{ heightValue ? `${heightValue}cm(第 1 步填的)` : '在第 1 步填' }}</span>
+                    </div>
+                  </div>
+                </div>
+                <div v-if="otherFields.length" class="more-group">
+                  <div class="more-group__title">参数<span class="ss-muted">没写卖点时,会从这些资料里整理卖点和参数图</span></div>
+                  <div class="material-grid">
+                    <label v-for="key in otherFields" :key="key" class="material-item">
+                      <span>{{ key }}</span>
+                      <el-input v-model="materialValues[key]" size="small" clearable :placeholder="fieldHints[key] || '可不填'" />
+                    </label>
+                  </div>
+                </div>
+                <div class="more-group">
+                  <div class="more-group__title">资料 Excel<span class="ss-muted">上面空着的项会从表里补;没写卖点时也会用表里的卖点</span></div>
+                  <div class="excel-row">
+                    <el-button size="small" @click="pickExcel">选择 Excel</el-button>
+                    <template v-if="excelPath">
+                      <span class="excel-row__name" :title="excelPath">{{ fileName(excelPath) }}</span>
+                      <el-button size="small" type="text" @click="excelPath = ''">移除</el-button>
+                    </template>
+                    <span v-else class="ss-muted">两列「字段 / 内容」,或者一行一个货号、表头带「货号」</span>
+                  </div>
+                </div>
+              </el-collapse-item>
+            </el-collapse>
+          </section>
+
+          <section class="ss-card">
+            <div class="ss-card__head">
+              <div class="ss-card__title">文字放在哪</div>
+              <div class="ss-card__desc">这一套所有带字的图都用同一个位置,选不挡商品的那个。</div>
+            </div>
+            <div class="anchor-grid">
+              <div
+                v-for="item in anchorOptions"
+                :key="item.value"
+                class="anchor-card"
+                :class="{ 'is-on': copyAnchor === item.value }"
+                @click="copyAnchor = item.value"
+              >
+                <div class="anchor-frame">
+                  <span class="anchor-frame__goods" :class="`is-${layout}`" />
+                  <span class="anchor-frame__text" :class="`at-${item.value}`"><i /><i /></span>
+                </div>
+                <div class="anchor-card__label">{{ item.label }}</div>
+              </div>
+            </div>
+          </section>
+        </div>
+
+        <div v-show="step === 3" class="ss-stack">
+          <section v-if="phase === 'idle'" class="ss-card">
+            <div class="ss-card__head">
+              <div class="ss-card__title">确认一下,没问题就开始生成</div>
+              <div class="ss-card__desc">生成要几分钟,期间不要关闭这个窗口,做完会自动保存。</div>
+            </div>
+            <div class="confirm-list">
+              <div class="confirm-row"><span>货号</span><b>{{ goodsArtNo }}</b></div>
+              <div class="confirm-row">
+                <span>大小</span>
+                <b>{{ tierInfo.name }}({{ tierInfo.range }})· {{ layoutLabel }}拍法{{ heightValue ? ` · 实测 ${heightValue}cm` : '' }}</b>
+              </div>
+              <div class="confirm-row"><span>场景图</span><b>{{ sceneSummary }}</b></div>
+              <div class="confirm-row"><span>卖点</span><b>{{ pointSummary }}</b></div>
+              <div class="confirm-row">
+                <span>本机排版</span>
+                <b>{{ freeItems.join('、') }}<small v-if="threeViewMissing.length">(缺{{ threeViewMissing.join('、') }},不出三视图)</small></b>
+              </div>
+              <div class="confirm-row"><span>文字位置</span><b>{{ anchorLabel }}</b></div>
+              <div class="confirm-row confirm-row--coin">
+                <span>金币</span>
+                <b>{{ coinInfo.text }}<small v-if="coinInfo.detail">{{ coinInfo.detail }}</small></b>
+              </div>
+              <div class="confirm-row"><span>保存到</span><b>货号文件夹里的「场景套图」</b></div>
+            </div>
+          </section>
+
+          <section v-else class="ss-card">
+            <div class="run-head">
+              <span class="run-head__icon" :class="`is-${phaseView.tone}`">
+                <el-icon v-if="phaseView.tone === 'ok'"><CircleCheckFilled /></el-icon>
+                <el-icon v-else-if="phaseView.tone === 'bad'"><CircleCloseFilled /></el-icon>
+                <el-icon v-else-if="phaseView.tone === 'warn'"><WarningFilled /></el-icon>
+                <el-icon v-else class="is-loading"><Loading /></el-icon>
+              </span>
+              <div class="run-head__text">
+                <div class="run-head__title">{{ phaseView.title }}</div>
+                <div class="run-head__desc">{{ phaseView.desc }}</div>
+              </div>
+            </div>
+            <el-progress
+              v-if="phase !== 'skipped'"
+              :percentage="percent"
+              :stroke-width="10"
+              :status="progressStatus"
+              :color="phase === 'done' ? '#67c23a' : '#2957ff'"
+            />
+            <div v-if="pollError && busy" class="ss-muted">{{ pollError }}</div>
+            <el-alert v-if="resumed && busy" type="info" :closable="false" show-icon title="接着上次没做完的任务">
+              <div class="alert-body">
+                <div>这个货号上次的任务还没做完,现在接着做,不会重复扣金币。</div>
+                <div class="alert-actions"><el-button size="small" @click="confirmForce">放弃上次,重新生成</el-button></div>
+              </div>
+            </el-alert>
+
+            <div v-if="phase === 'done' && resultImages.length" class="result-grid">
+              <div v-for="(path, index) in resultImages" :key="path" class="result-card">
+                <el-image :src="resultUrls[index]" fit="contain" :preview-src-list="resultUrls" :initial-index="index" preview-teleported />
+                <div class="result-card__name">{{ fileStem(path) }}</div>
+              </div>
+            </div>
+            <div v-else-if="sortedItems.length" class="run-grid">
+              <div v-for="item in sortedItems" :key="item.id || item.scene_name" class="run-card">
+                <div class="run-card__img">
+                  <el-image
+                    v-if="item.result_url"
+                    :src="item.result_url"
+                    fit="cover"
+                    :preview-src-list="progressUrls"
+                    :initial-index="Math.max(0, progressUrls.indexOf(item.result_url))"
+                    preview-teleported
+                  />
+                  <div v-else class="run-card__placeholder" :class="`is-${itemTone(item)}`">
+                    <el-icon v-if="[0, 1].includes(Number(item.status))" class="is-loading"><Loading /></el-icon>
+                    <span>{{ itemPlaceholder(item) }}</span>
+                  </div>
+                </div>
+                <div class="run-card__name">{{ item.scene_name || item.kind }}</div>
+                <div class="run-card__status" :class="`is-${itemTone(item)}`" :title="item.error_msg || ''">{{ statusText(item) }}</div>
+              </div>
+            </div>
+
+            <div v-if="displayNotes.length" class="run-notes">
+              <div class="run-notes__title">说明</div>
+              <div v-for="note in displayNotes" :key="note">· {{ note }}</div>
+            </div>
+          </section>
+        </div>
+      </div>
+    </div>
+
+    <div class="ss-footer">
+      <div class="ss-footer__side">
+        <el-button v-if="canBack" @click="goStep(step - 1)">上一步</el-button>
+      </div>
+      <div class="ss-footer__info">
+        <span v-if="currentBlock" class="ss-footer__warn">
+          <el-icon><WarningFilled /></el-icon>{{ currentBlock }}
+        </span>
+        <span v-else>{{ footerSummary }}</span>
+      </div>
+      <div class="ss-footer__side ss-footer__side--right">
+        <el-button v-if="step < 3" type="primary" :disabled="!!currentBlock" @click="goStep(step + 1)">下一步</el-button>
+        <template v-else>
+          <el-button v-if="phase === 'idle'" type="primary" @click="submitGenerate">开始生成</el-button>
+          <el-button v-else-if="busy" type="primary" loading>生成中,请不要关闭窗口</el-button>
+          <template v-else-if="phase === 'failed'">
+            <el-button v-if="failedStage === 'save'" type="primary" @click="retrySave">重新保存</el-button>
+            <el-button v-else type="primary" @click="retryGenerate">重新生成</el-button>
+          </template>
+          <template v-else>
+            <el-button v-if="resultDir" :icon="FolderOpened" @click="openDir(resultDir)">打开结果文件夹</el-button>
+            <el-button type="primary" @click="restart">再做一个货号</el-button>
+          </template>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
+import {
+  Check,
+  CircleCheckFilled,
+  CircleCloseFilled,
+  FolderOpened,
+  Loading,
+  RefreshLeft,
+  RefreshRight,
+  WarningFilled,
+} from '@element-plus/icons-vue'
+import BlueHeaderBar from '@/components/header-bar/blue-header.vue'
+import GoodsArtNoSearch from '@/views/Photography/poster/GoodsArtNoSearch.vue'
+import client from '@/stores/modules/client'
+import tokenInfo from '@/stores/modules/token'
+import icpList from '@/utils/ipc'
+import { fetchSceneSetCategories, fetchSceneSetJobs } from '@/apis/sceneSet'
+
+type Phase = 'idle' | 'submitting' | 'running' | 'saving' | 'done' | 'skipped' | 'failed'
+
+const steps = [
+  { no: 1, title: '选商品', desc: '货号、大小、抠图' },
+  { no: 2, title: '定内容', desc: '场景、卖点、文字位置' },
+  { no: 3, title: '生成', desc: '确认金币,开始出图' },
+]
+const tierOptions = [
+  { value: 'S', name: '小号', range: '高度 25cm 以下', desc: '桌面、随身大小', layout: 'handheld' },
+  { value: 'M', name: '中号', range: '高度 25~55cm', desc: '手提大小,横放着拍', layout: 'handheld' },
+  { value: 'L', name: '大号', range: '高度 55cm 以上', desc: '落地大小,立着拍', layout: 'floor' },
+]
+const defaultAnchors = [
+  { value: 'top-left', label: '左上' },
+  { value: 'top-right', label: '右上' },
+  { value: 'bottom-left', label: '左下' },
+  { value: 'bottom-right', label: '右下' },
+  { value: 'center', label: '中间' },
+]
+const defaultLayouts = [
+  { value: 'handheld', label: '横放扁手提' },
+  { value: 'floor', label: '落地' },
+]
+const sizeKeys = ['宽度', '深度', '重量']
+const hiddenKeys = ['卖点', '卖点文案', '货号', '高度', '高', '宽', '深', '尺寸']
+const verifiedLabels = ['品牌', '横放造型', '顶部提手', '橙色两端', '黑色织布', '顶面按键']
+const threeViewRoles = [['front', '正面'], ['side', '侧面'], ['top', '顶面']]
+const fieldHints: Record<string, string> = {
+  宽度: '如 32cm',
+  深度: '如 15cm',
+  重量: '如 2.5kg',
+  续航: '如 12小时',
+  蓝牙: '如 5.3',
+  功率: '如 40W',
+  防水: '如 IPX6',
+}
+const sellingPlaceholder = '例如:\n重低音,聚会更带劲\n七彩灯效,夜里更有氛围\n顶部提手,拎着就走'
+
+const clientStore = client()
+const tokenStore = tokenInfo()
+const mainRef = ref<HTMLElement | null>(null)
+const step = ref(1)
+
+const categoryState = ref<'loading' | 'ready' | 'error'>('loading')
+const categoryError = ref('')
+const categories = ref<any[]>([])
+const anchors = ref<any[]>([])
+const layouts = ref<any[]>([])
+const categoryId = ref<number | null>(null)
+
+const goodsArtNo = ref('')
+const goodsMeta = ref('')
+const imagePath = ref('')
+const goodsDir = ref('')
+const preparing = ref(false)
+const cutting = ref(false)
+const prepareError = ref('')
+const prepareCode = ref(0)
+const outputExists = ref(false)
+const outputDir = ref('')
+const cutouts = ref<any[]>([])
+const editingPath = ref('')
+
+const tier = ref('M')
+const heightCm = ref('')
+const layout = ref('handheld')
+const layoutTouched = ref(false)
+
+const basis = ref('selling_point')
+const sceneTags = ref<string[]>([])
+const sellingPoints = ref('')
+const visibleKeys = ref<string[]>([])
+const materialValues = reactive<Record<string, string>>({})
+const excelPath = ref('')
+const moreOpen = ref<string[]>([])
+const copyAnchor = ref('top-left')
+
+const phase = ref<Phase>('idle')
+const failedStage = ref<'' | 'create' | 'scenes' | 'save'>('')
+const errorText = ref('')
+const skipReason = ref('')
+const resumed = ref(false)
+const progressItems = ref<any[]>([])
+const jobNotes = ref<string[]>([])
+const jobTier = ref('')
+const pollError = ref('')
+const savedFiles = ref<string[]>([])
+const savedNotes = ref<string[]>([])
+const resultDir = ref('')
+const savedAt = ref(0)
+
+let pollTimer: ReturnType<typeof setTimeout> | null = null
+let polling = false
+let jobId = 0
+let forceNext = 0
+let runToken = 0
+let prepareSeq = 0
+let cutSeq = 0
+let lastJob: any = null
+
+const currentCategory = computed(() => categories.value.find((item) => item.id === categoryId.value) || null)
+const sceneCoin = computed(() => Number(currentCategory.value?.scene_coin || 0))
+const analyzeCoin = computed(() => Number(currentCategory.value?.analyze_coin || 0))
+const visibleFeatures = computed<any[]>(() => currentCategory.value?.visible_features || [])
+const materialFields = computed<string[]>(() => currentCategory.value?.material_fields || [])
+const sizeFields = computed(() => sizeKeys.filter((key) => materialFields.value.includes(key)))
+const otherFields = computed(() => materialFields.value.filter((key) => !hiddenKeys.includes(key) && !sizeKeys.includes(key)))
+const editableFields = computed(() => [...sizeFields.value, ...otherFields.value])
+const anchorOptions = computed(() => (anchors.value.length ? anchors.value : defaultAnchors))
+const anchorLabel = computed(() => anchorOptions.value.find((item: any) => item.value === copyAnchor.value)?.label || copyAnchor.value)
+const layoutSlots = computed(() => (currentCategory.value?.slots || [])
+  .filter((slot: any) => slot.layout === layout.value)
+  .sort((a: any, b: any) => Number(a.seq) - Number(b.seq)))
+const layoutOptions = computed(() => {
+  const list = layouts.value.length ? layouts.value : defaultLayouts
+  const slots = currentCategory.value?.slots || []
+  return list.map((item: any) => {
+    const count = slots.filter((slot: any) => slot.layout === item.value).length
+    return { value: item.value, label: count ? `${item.label}(${count} 个机位)` : item.label }
+  })
+})
+const layoutLabel = computed(() => {
+  const list = layouts.value.length ? layouts.value : defaultLayouts
+  return list.find((item: any) => item.value === layout.value)?.label || layout.value
+})
+const tierInfo = computed(() => tierOptions.find((item) => item.value === tier.value) || tierOptions[1])
+const tierScenes = computed(() => scenesOf(tier.value))
+const heightValue = computed(() => {
+  const value = Number(heightCm.value)
+  return heightCm.value !== '' && value > 0 && value < 1000 ? value : 0
+})
+
+const plateCutout = computed(() => cutouts.value.find((item) => Number(item.use_for_scene) && Number(item.use_long_scene)) || null)
+const editingCutout = computed(() => cutouts.value.find((item) => item.path === editingPath.value) || null)
+const duplicateSlots = computed(() => {
+  const count: Record<string, number> = {}
+  cutouts.value.forEach((item) => {
+    if (item.slot_id) count[item.slot_id] = (count[item.slot_id] || 0) + 1
+  })
+  return new Set(cutouts.value.map((item) => item.slot_id).filter((id) => id && count[id] > 1))
+})
+const threeViewMissing = computed(() => {
+  const roles = new Set(cutouts.value.filter((item) => Number(item.use_three_view)).map((item) => item.role))
+  return threeViewRoles.filter(([role]) => !roles.has(role)).map(([, label]) => label)
+})
+
+const sellingLines = computed(() => sellingPoints.value.split(/\r\n|\n|\r|;|;/).map((text) => text.trim()).filter(Boolean))
+const hasMaterials = computed(() => !!excelPath.value || editableFields.value.some((key) => (materialValues[key] || '').trim()))
+const verifiedKeys = computed(() => visibleFeatures.value.filter((item) => verifiedLabels.includes(item.label)).map((item) => item.key))
+const sizeReady = computed(() => !!heightValue.value
+  && sizeFields.value.length === sizeKeys.length
+  && sizeKeys.every((key) => /\d/.test(materialValues[key] || '')))
+
+const autoRange = computed(() => {
+  const list = tierScenes.value
+  const max = Number(currentCategory.value?.max_scene_count || 8)
+  const fallback = Number(currentCategory.value?.default_scene_count || 6)
+  const open = list.filter((item: any) => !(item.requires || []).length).length
+  const high = Math.min(max, list.length)
+  return { low: Math.min(fallback, open, high), high, max }
+})
+const autoCountText = computed(() => {
+  const { low, high } = autoRange.value
+  return low === high ? `${high} 张` : `${low}~${high} 张`
+})
+const coinInfo = computed(() => {
+  const unit = sceneCoin.value
+  if (basis.value === 'scene') {
+    const count = sceneTags.value.length
+    if (!unit) return { text: '不扣金币', detail: '' }
+    return { text: `${count * unit} 金币`, detail: `${count} 张场景图 × ${unit} 金币,没出来的会退回` }
+  }
+  const { low, high, max } = autoRange.value
+  const analyze = analyzeCoin.value
+  const least = analyze + low * unit
+  const most = analyze + high * unit
+  if (!most) return { text: '不扣金币', detail: '' }
+  return {
+    text: least === most ? `约 ${most} 金币` : `约 ${least}~${most} 金币`,
+    detail: `每张场景图 ${unit} 金币${analyze ? `,整理卖点 ${analyze} 金币` : ''},按实际张数扣,没出来的会退回。开始时账户里至少要有 ${analyze + max * unit} 金币`,
+  }
+})
+const sceneSummary = computed(() => {
+  if (basis.value === 'selling_point') return `按卖点自动挑 ${autoCountText.value}`
+  const names = tierScenes.value.filter((item: any) => sceneTags.value.includes(item.tag)).map((item: any) => item.name)
+  return `${names.length} 张:${names.join('、')}`
+})
+const pointSummary = computed(() => {
+  if (sellingLines.value.length) return `照你写的 ${sellingLines.value.length} 条印在图上`
+  if (hasMaterials.value) return '从填的资料里整理'
+  if (visibleKeys.value.length) return `用图上看得到的 ${visibleKeys.value.length} 项特征`
+  return '没有卖点,按通用场景出图'
+})
+const freeItems = computed(() => {
+  const list: string[] = []
+  if (!threeViewMissing.value.length) list.push('三视图')
+  if (cutouts.value.some((item) => Number(item.use_detail))) list.push('细节图')
+  if (sizeReady.value) list.push('尺寸图')
+  list.push('卖点说明(卖点.txt)')
+  return list
+})
+
+const busy = computed(() => ['submitting', 'running', 'saving'].includes(phase.value))
+const locked = computed(() => busy.value
+  || phase.value === 'done'
+  || phase.value === 'skipped'
+  || (phase.value === 'failed' && failedStage.value === 'save'))
+const currentBlock = computed(() => (step.value < 3 ? blockFor(step.value) : ''))
+const canBack = computed(() => step.value > 1 && !locked.value)
+const footerSummary = computed(() => {
+  if (step.value === 3) {
+    if (phase.value === 'idle') return `${coinInfo.value.text},确认没问题就点「开始生成」`
+    if (busy.value) return '可以把窗口最小化,做完会自动保存'
+    return ''
+  }
+  if (!goodsArtNo.value) return ''
+  return [
+    goodsArtNo.value,
+    tierInfo.value.name,
+    basis.value === 'scene' ? `已选 ${sceneTags.value.length} 个场景` : `自动挑 ${autoCountText.value}`,
+    coinInfo.value.text,
+  ].join(' · ')
+})
+
+const sortedItems = computed(() => [...progressItems.value].sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0)))
+const sceneItems = computed(() => progressItems.value.filter((item) => item.kind === 'scene'))
+const finishedCount = computed(() => sceneItems.value.filter((item) => [2, -1].includes(Number(item.status))).length)
+const failedCount = computed(() => sceneItems.value.filter((item) => Number(item.status) === -1).length)
+const progressUrls = computed(() => sortedItems.value.filter((item) => item.result_url).map((item) => item.result_url))
+const resultImages = computed(() => savedFiles.value.filter((path) => /\.png$/i.test(path)))
+const resultUrls = computed(() => resultImages.value.map((path) => toFileUrl(path, savedAt.value)))
+const percent = computed(() => {
+  if (phase.value === 'done') return 100
+  if (phase.value === 'saving') return 95
+  const total = sceneItems.value.length
+  if (!total) return phase.value === 'running' ? 5 : 0
+  return Math.max(5, Math.round((finishedCount.value / total) * 90))
+})
+const progressStatus = computed(() => {
+  if (phase.value === 'done') return 'success'
+  if (phase.value === 'failed') return 'exception'
+  return ''
+})
+const displayNotes = computed(() => {
+  const notes = [...(phase.value === 'done' ? savedNotes.value : jobNotes.value)]
+  if (jobTier.value && jobTier.value !== tier.value) {
+    notes.unshift(`资料里的高度对应${tierName(jobTier.value)},实际按${tierName(jobTier.value)}出图`)
+  }
+  return Array.from(new Set(notes.filter(Boolean)))
+})
+const phaseView = computed(() => {
+  const total = sceneItems.value.length
+  switch (phase.value) {
+    case 'submitting':
+      return { tone: 'run', title: '正在准备', desc: '正在用场景底图做底板、上传,并创建任务…' }
+    case 'running':
+      return { tone: 'run', title: '正在生成场景图', desc: `${finishedCount.value}/${total} 张已处理。可以把窗口最小化,做完会自动保存到货号文件夹。` }
+    case 'saving':
+      return { tone: 'run', title: '正在保存', desc: '正在给场景图叠文字,并在本机排三视图、细节图…' }
+    case 'done':
+      return {
+        tone: 'ok',
+        title: '做好了',
+        desc: `共 ${resultImages.value.length} 张图,已保存到:${resultDir.value}${failedCount.value ? `。有 ${failedCount.value} 张场景图没出来,金币已退回` : ''}`,
+      }
+    case 'skipped':
+      return { tone: 'warn', title: '没有生成', desc: skipReason.value }
+    case 'failed':
+      if (failedStage.value === 'scenes') {
+        return { tone: 'bad', title: '场景图没有生成成功', desc: '场景图的金币已经退回。点「重新生成」可以再试一次,会重新扣金币。' }
+      }
+      if (failedStage.value === 'save') {
+        return { tone: 'bad', title: '图已经生成,但保存失败', desc: `${errorText.value}。点「重新保存」就行,不会再扣金币。` }
+      }
+      return { tone: 'bad', title: '没有开始生成', desc: errorText.value }
+    default:
+      return { tone: 'run', title: '', desc: '' }
+  }
+})
+const prepareErrorView = computed(() => {
+  if (prepareCode.value === 999) {
+    return { title: '本机服务没有响应', desc: '请确认客户端已经完整启动,再点「重新读取」。' }
+  }
+  if (prepareError.value.includes('抠图')) {
+    return {
+      title: '这个货号还没有抠好的图',
+      desc: '点「重新抠图」,会用这个货号已经拍好的图抠一遍,完成后自动读进来。',
+    }
+  }
+  return { title: '读取抠图失败', desc: prepareError.value }
+})
+const missingCutout = computed(() => prepareCode.value !== 999 && prepareError.value.includes('抠图'))
+
+watch(heightCm, () => {
+  if (heightValue.value) applyTier(tierFromHeight(heightValue.value))
+})
+
+function scenesOf(value: string) {
+  return (currentCategory.value?.scenes || []).filter((item: any) => item.tier === value)
+}
+
+function sceneNames(value: string) {
+  return scenesOf(value).map((item: any) => item.name).join('、')
+}
+
+function tierName(value: string) {
+  const item = tierOptions.find((row) => row.value === value)
+  return item ? `${item.name}(${item.value})` : value
+}
+
+function tierFromHeight(height: number) {
+  if (height < 25) return 'S'
+  if (height < 55) return 'M'
+  return 'L'
+}
+
+function toFileUrl(path: string, version = 0) {
+  if (!path) return ''
+  if (/^(data:|https?:|file:)/i.test(path)) return path
+  const url = 'file:///' + encodeURI(path.replace(/\\/g, '/')).replace(/#/g, '%23').replace(/\?/g, '%3F')
+  return version ? `${url}?t=${version}` : url
+}
+
+function fileName(path: string) {
+  return (path || '').split(/[\\/]/).pop() || ''
+}
+
+function fileStem(path: string) {
+  return fileName(path).replace(/\.[^.]+$/, '')
+}
+
+function plain<T>(value: T): T {
+  return JSON.parse(JSON.stringify(value))
+}
+
+function errorMessage(error: any, fallback: string) {
+  const status = error?.response?.status
+  if (status) return `服务端返回 ${status},请稍后重试`
+  return error?.message || error?.msg || fallback
+}
+
+function firstImagePath(item: any) {
+  const rows = item?.items || []
+  for (const row of rows) {
+    const path = row?.PhotoRecord?.image_path || row?.image_path
+    if (path) return path
+  }
+  return ''
+}
+
+function scrollTop() {
+  nextTick(() => mainRef.value?.scrollTo({ top: 0 }))
+}
+
+async function loadCategories() {
+  categoryState.value = 'loading'
+  categoryError.value = ''
+  try {
+    const res: any = await fetchSceneSetCategories()
+    const data = res?.data || {}
+    categories.value = data.categories || []
+    anchors.value = data.copy_anchors || []
+    layouts.value = data.layouts || []
+    if (!categories.value.some((item) => item.id === categoryId.value)) {
+      categoryId.value = categories.value[0]?.id ?? null
+    }
+    categoryState.value = 'ready'
+    if (goodsArtNo.value && imagePath.value && !cutouts.value.length) loadCutouts()
+  } catch (error: any) {
+    categoryState.value = 'error'
+    categoryError.value = errorMessage(error, '请确认已经登录,再点重试')
+  }
+}
+
+function onCategory() {
+  sceneTags.value = []
+  visibleKeys.value = []
+  loadCutouts()
+}
+
+function resetGoods() {
+  prepareSeq += 1
+  cutSeq += 1
+  preparing.value = false
+  if (cutting.value && !locked.value) setClosable(true)
+  cutting.value = false
+  goodsDir.value = ''
+  cutouts.value = []
+  outputExists.value = false
+  outputDir.value = ''
+  prepareError.value = ''
+  prepareCode.value = 0
+  editingPath.value = ''
+}
+
+function onGoods(item: any) {
+  imagePath.value = firstImagePath(item)
+  goodsMeta.value = item ? [`${item.items?.length || 0} 张拍摄图`, item.action_time].filter(Boolean).join(' · ') : ''
+  resetGoods()
+  if (item) loadCutouts()
+}
+
+async function loadCutouts() {
+  if (!goodsArtNo.value || !imagePath.value || !currentCategory.value) return
+  const seq = ++prepareSeq
+  preparing.value = true
+  prepareError.value = ''
+  prepareCode.value = 0
+  editingPath.value = ''
+  try {
+    const result = await clientStore.ipc.invoke(icpList.generate.sceneSetPrepare, plain({
+      goods_art_no: goodsArtNo.value,
+      image_path: imagePath.value,
+      layout: layout.value,
+      slots: currentCategory.value.slots || [],
+    }))
+    if (seq !== prepareSeq) return
+    if (!result || result.code !== 0) {
+      cutouts.value = []
+      prepareCode.value = Number(result?.code || 1)
+      prepareError.value = result?.msg || '读取抠图失败'
+      return
+    }
+    const data = result.data || {}
+    goodsDir.value = data.goods_dir || ''
+    outputDir.value = data.output_dir || ''
+    outputExists.value = !!data.output_exists
+    cutouts.value = (data.cutouts || []).map((item: any) => ({ ...item }))
+  } catch (error: any) {
+    if (seq !== prepareSeq) return
+    cutouts.value = []
+    prepareCode.value = 1
+    prepareError.value = errorMessage(error, '读取抠图失败')
+  } finally {
+    if (seq === prepareSeq) preparing.value = false
+  }
+}
+
+async function recut() {
+  if (!goodsArtNo.value || cutting.value) return
+  const token = tokenStore.getToken
+  if (!token) {
+    ElMessage.error('请先登录,再重新抠图')
+    return
+  }
+  const seq = ++cutSeq
+  cutting.value = true
+  setClosable(false)
+  try {
+    const result = await clientStore.ipc.invoke(icpList.generate.sceneSetCutout, plain({
+      goods_art_nos: [goodsArtNo.value],
+      token,
+    }))
+    if (seq !== cutSeq) return
+    if (!result || result.code !== 0) {
+      ElMessage.error(result?.msg || result?.message || '抠图失败')
+      return
+    }
+    await loadCutouts()
+  } catch (error: any) {
+    if (seq !== cutSeq) return
+    ElMessage.error(errorMessage(error, '抠图失败'))
+  } finally {
+    if (seq === cutSeq) {
+      cutting.value = false
+      if (!locked.value) setClosable(true)
+    }
+  }
+}
+
+function applyTier(value: string) {
+  if (tier.value === value) return
+  tier.value = value
+  const tags = new Set(scenesOf(value).map((item: any) => item.tag))
+  sceneTags.value = sceneTags.value.filter((tag) => tags.has(tag))
+  const next = tierOptions.find((item) => item.value === value)?.layout || 'handheld'
+  if (!layoutTouched.value && layout.value !== next) {
+    layout.value = next
+    loadCutouts()
+  }
+}
+
+function pickTier(value: string) {
+  if (heightValue.value && tierFromHeight(heightValue.value) !== value) {
+    heightCm.value = ''
+    ElMessage.info('已清空实测高度,按你选的大小出图')
+  }
+  applyTier(value)
+}
+
+function onHeightInput(value: string) {
+  const text = String(value || '').replace(/[^\d.]/g, '')
+  const [whole, ...rest] = text.split('.')
+  heightCm.value = (rest.length ? `${whole}.${rest.join('')}` : whole).slice(0, 6)
+}
+
+function onLayout() {
+  layoutTouched.value = true
+  loadCutouts()
+}
+
+function isPlate(item: any) {
+  return !!plateCutout.value && plateCutout.value.path === item.path
+}
+
+function isUnused(item: any) {
+  return cutoutTags(item)[0].type === 'off'
+}
+
+function cutoutTags(item: any) {
+  const tags: Array<{ text: string; type: string }> = []
+  if (isPlate(item)) tags.push({ text: '场景底图', type: 'plate' })
+  if (Number(item.use_three_view) && threeViewRoles.some(([role]) => role === item.role)) tags.push({ text: '三视图', type: 'view' })
+  if (Number(item.use_detail)) tags.push({ text: '细节图', type: 'detail' })
+  if (!tags.length) tags.push({ text: '不使用', type: 'off' })
+  return tags
+}
+
+function toggleEdit(item: any) {
+  editingPath.value = editingPath.value === item.path ? '' : item.path
+}
+
+function onSlot(item: any) {
+  const slot = layoutSlots.value.find((row: any) => row.id === item.slot_id)
+  if (!slot) {
+    Object.assign(item, {
+      slot_id: 0,
+      name: '不使用',
+      role: '',
+      use_long_scene: 0,
+      use_three_view: 0,
+      use_detail: 0,
+      use_for_scene: 0,
+      crops: [],
+    })
+    return
+  }
+  Object.assign(item, {
+    name: slot.name,
+    role: slot.role,
+    rotation: slot.rotation,
+    use_long_scene: slot.use_long_scene,
+    use_three_view: slot.use_three_view,
+    use_detail: slot.use_detail,
+    use_for_scene: slot.use_for_scene,
+    crops: plain(slot.crops || []),
+  })
+}
+
+function rotate(item: any, delta: number) {
+  item.rotation = (((Number(item.rotation) || 0) + delta) % 360 + 360) % 360
+}
+
+function setPlate(item: any) {
+  cutouts.value.forEach((row) => {
+    row.use_for_scene = row.path === item.path ? 1 : 0
+  })
+  item.use_long_scene = 1
+}
+
+function toggleScene(tag: string) {
+  sceneTags.value = sceneTags.value.includes(tag)
+    ? sceneTags.value.filter((item) => item !== tag)
+    : [...sceneTags.value, tag]
+}
+
+function toggleVisible(key: string) {
+  visibleKeys.value = visibleKeys.value.includes(key)
+    ? visibleKeys.value.filter((item) => item !== key)
+    : [...visibleKeys.value, key]
+}
+
+function fillVerified() {
+  visibleKeys.value = Array.from(new Set([...visibleKeys.value, ...verifiedKeys.value]))
+}
+
+async function pickExcel() {
+  try {
+    const file = await clientStore.ipc.invoke(icpList.utils.openFile, {
+      title: '选择资料 Excel',
+      filters: [{ name: 'Excel', extensions: ['xlsx', 'xls'] }],
+    })
+    const path = Array.isArray(file) ? file[0] || '' : file || ''
+    if (path) excelPath.value = path
+  } catch (error) {
+    // 取消选择时主进程会直接抛错
+  }
+}
+
+function selectedVisible() {
+  return visibleFeatures.value.filter((item: any) => visibleKeys.value.includes(item.key))
+}
+
+function blockFor(no: number) {
+  if (no === 1) {
+    if (categoryState.value === 'loading') return '正在加载品类…'
+    if (categoryState.value === 'error') return '品类没有加载出来,点「重试」'
+    if (!currentCategory.value) return '暂时没有可用的品类'
+    if (!goodsArtNo.value) return '先选一个货号'
+    if (!imagePath.value) return '这个货号没有拍摄图,换一个货号'
+    if (cutting.value) return '正在抠图…'
+    if (preparing.value) return '正在读取抠图…'
+    if (prepareError.value) return prepareErrorView.value.title
+    if (outputExists.value) return '这个货号已经做过,要重做先删掉「场景套图」文件夹'
+    if (!cutouts.value.length) return '没有读到抠图'
+    if (!plateCutout.value) return '还没有场景底图,点一张抠图设为场景底图'
+    return ''
+  }
+  if (no === 2) {
+    if (basis.value === 'scene' && !sceneTags.value.length) return '至少选一个场景'
+  }
+  return ''
+}
+
+function canEnter(no: number) {
+  if (locked.value) return no === step.value
+  for (let index = 1; index < no; index += 1) {
+    if (blockFor(index)) return false
+  }
+  return true
+}
+
+function goStep(no: number) {
+  if (no < 1 || no > steps.length || no === step.value || !canEnter(no)) return
+  if (step.value === 3 && phase.value === 'failed') resetRun()
+  editingPath.value = ''
+  step.value = no
+  scrollTop()
+}
+
+function setClosable(closable: boolean) {
+  clientStore.ipc?.invoke(icpList.utils.setWindowClosable, { id: 'sceneSet', closable })?.catch(() => {})
+}
+
+function resetRun() {
+  runToken += 1
+  stopPoll()
+  phase.value = 'idle'
+  failedStage.value = ''
+  errorText.value = ''
+  skipReason.value = ''
+  resumed.value = false
+  progressItems.value = []
+  jobNotes.value = []
+  jobTier.value = ''
+  pollError.value = ''
+  savedFiles.value = []
+  savedNotes.value = []
+  resultDir.value = ''
+  lastJob = null
+  jobId = 0
+}
+
+function buildFields() {
+  const fields: Record<string, string> = {}
+  editableFields.value.forEach((key) => {
+    const value = (materialValues[key] || '').trim()
+    if (value) fields[key] = value
+  })
+  if (heightValue.value && !fields['高度'] && (Object.keys(fields).length || excelPath.value)) {
+    fields['高度'] = `${heightValue.value}cm`
+  }
+  return fields
+}
+
+function runErrorText(result: any) {
+  if (result?.code === 999) return '本机服务没有响应,请确认客户端已经完整启动后再试'
+  const msg = result?.msg || '创建任务失败'
+  if (msg.includes('没有可用于场景的机位')) return '没有场景底图。回到第 1 步,点一张抠图设为场景底图'
+  if (msg.includes('金币不足')) return `金币不足。这次开始时账户里至少要有 ${basis.value === 'scene' ? sceneTags.value.length * sceneCoin.value : analyzeCoin.value + autoRange.value.max * sceneCoin.value} 金币`
+  return msg
+}
+
+async function submitGenerate() {
+  if (busy.value) return
+  const block = blockFor(1) || blockFor(2)
+  if (block) {
+    ElMessage.warning(block)
+    return
+  }
+  resetRun()
+  const token = runToken
+  phase.value = 'submitting'
+  setClosable(false)
+  try {
+    const result = await clientStore.ipc.invoke(icpList.generate.sceneSetRun, plain({
+      goods_art_no: goodsArtNo.value,
+      image_path: imagePath.value,
+      goods_dir: goodsDir.value,
+      layout: layout.value,
+      basis: basis.value,
+      copy_anchor: copyAnchor.value,
+      category_id: categoryId.value,
+      scene_tags: basis.value === 'scene' ? sceneTags.value : [],
+      selling_points: sellingPoints.value.trim(),
+      materials: buildFields(),
+      visible_features: selectedVisible(),
+      height_cm: heightValue.value ? String(heightValue.value) : '',
+      missing_height: 'default',
+      default_tier: tier.value,
+      cutouts: cutouts.value,
+      excel_path: excelPath.value,
+      force: forceNext,
+    }))
+    if (token !== runToken) return
+    if (!result || result.code !== 0) throw new Error(runErrorText(result))
+    const data = result.data || {}
+    if (data.skipped) {
+      forceNext = 0
+      phase.value = 'skipped'
+      skipReason.value = data.reason || '已跳过'
+      resultDir.value = data.output_dir || ''
+      setClosable(true)
+      return
+    }
+    if (!data.job_id) throw new Error(data.reason || '没有创建任务')
+    forceNext = 0
+    jobId = data.job_id
+    goodsDir.value = data.goods_dir || goodsDir.value
+    resumed.value = !!data.resumed
+    progressItems.value = data.images || []
+    jobNotes.value = data.progress_notes || []
+    jobTier.value = data.tier || ''
+    phase.value = 'running'
+    startPoll(token)
+  } catch (error: any) {
+    if (token !== runToken) return
+    phase.value = 'failed'
+    failedStage.value = 'create'
+    errorText.value = errorMessage(error, '创建任务失败')
+    setClosable(true)
+  }
+}
+
+function startPoll(token: number) {
+  stopPoll()
+  polling = true
+  const tick = async () => {
+    if (!polling || token !== runToken) return
+    try {
+      const res: any = await fetchSceneSetJobs([jobId])
+      if (!polling || token !== runToken) return
+      pollError.value = ''
+      const job = (res?.data?.list || [])[0]
+      if (job) {
+        progressItems.value = job.images || []
+        jobNotes.value = job.progress_notes || []
+        jobTier.value = job.tier || jobTier.value
+        const status = Number(job.status)
+        if (status === 2 || status === 3) {
+          polling = false
+          await saveJob(job, token)
+          return
+        }
+        if (status === -1) {
+          polling = false
+          forceNext = 1
+          phase.value = 'failed'
+          failedStage.value = 'scenes'
+          setClosable(true)
+          return
+        }
+      }
+    } catch (error) {
+      if (token !== runToken) return
+      pollError.value = '查询进度失败,正在重试…'
+    }
+    if (polling && token === runToken) pollTimer = setTimeout(tick, 3000)
+  }
+  tick()
+}
+
+function stopPoll() {
+  polling = false
+  if (pollTimer) clearTimeout(pollTimer)
+  pollTimer = null
+}
+
+async function saveJob(job: any, token: number) {
+  lastJob = job
+  phase.value = 'saving'
+  failedStage.value = ''
+  setClosable(false)
+  try {
+    const result = await clientStore.ipc.invoke(icpList.generate.sceneSetSave, plain({
+      goods_dir: goodsDir.value,
+      copy_anchor: copyAnchor.value,
+      job,
+      cutouts: cutouts.value,
+    }))
+    if (token !== runToken) return
+    if (!result || result.code !== 0) {
+      throw new Error(result?.code === 999 ? '本机服务没有响应' : (result?.msg || '保存失败'))
+    }
+    const data = result.data || {}
+    resultDir.value = data.output_dir || ''
+    savedFiles.value = data.files || []
+    savedNotes.value = data.notes || []
+    savedAt.value = Date.now()
+    outputExists.value = true
+    phase.value = 'done'
+    setClosable(true)
+    ElNotification({
+      title: '卖点套图已保存',
+      message: `${goodsArtNo.value} 共 ${resultImages.value.length} 张图`,
+      type: 'success',
+      duration: 6000,
+    })
+  } catch (error: any) {
+    if (token !== runToken) return
+    phase.value = 'failed'
+    failedStage.value = 'save'
+    errorText.value = errorMessage(error, '保存失败')
+    setClosable(true)
+  }
+}
+
+function retrySave() {
+  if (lastJob) saveJob(lastJob, runToken)
+}
+
+function retryGenerate() {
+  resetRun()
+  scrollTop()
+}
+
+async function confirmForce() {
+  try {
+    await ElMessageBox.confirm('会重新创建任务,并重新扣金币;上次任务的图不再保存。确定吗?', '放弃上次的任务', {
+      confirmButtonText: '重新生成',
+      cancelButtonText: '再等等',
+      type: 'warning',
+    })
+  } catch (error) {
+    return
+  }
+  resetRun()
+  forceNext = 1
+  submitGenerate()
+}
+
+function restart() {
+  resetRun()
+  forceNext = 0
+  goodsArtNo.value = ''
+  goodsMeta.value = ''
+  imagePath.value = ''
+  resetGoods()
+  heightCm.value = ''
+  layoutTouched.value = false
+  layout.value = tierInfo.value.layout
+  sceneTags.value = []
+  sellingPoints.value = ''
+  visibleKeys.value = []
+  excelPath.value = ''
+  Object.keys(materialValues).forEach((key) => {
+    materialValues[key] = ''
+  })
+  moreOpen.value = []
+  step.value = 1
+  setClosable(true)
+  scrollTop()
+}
+
+function itemTone(item: any) {
+  const status = Number(item.status)
+  if (status === 2) return 'ok'
+  if (status === -1) return 'bad'
+  if (status === 4) return 'local'
+  return 'wait'
+}
+
+function itemPlaceholder(item: any) {
+  const status = Number(item.status)
+  if (status === 4) return '出完场景图后在本机排版'
+  if (status === -1) return item.error_msg || '没有生成'
+  if (status === 1) return '生成中'
+  return '排队中'
+}
+
+function statusText(item: any) {
+  const status = Number(item.status)
+  if (status === 4) return phase.value === 'done' ? '已保存' : '本机排版'
+  if (status === 2) return phase.value === 'done' ? '已保存' : '已出图'
+  if (status === 1) return '生成中'
+  if (status === 0) return '排队中'
+  if (status === -1) return '失败,已退金币'
+  return '处理中'
+}
+
+function openDir(path: string) {
+  if (!path) return
+  clientStore.ipc.removeAllListeners(icpList.utils.shellFun)
+  clientStore.ipc.send(icpList.utils.shellFun, {
+    action: 'openMkPath',
+    params: path.replace(/\//g, '\\'),
+  })
+}
+
+loadCategories()
+
+onBeforeUnmount(() => {
+  runToken += 1
+  stopPoll()
+})
+</script>
+
+<style scoped lang="scss">
+.ss-page {
+  height: calc(100vh - 50px);
+  display: flex;
+  flex-direction: column;
+  background: #f4f6fb;
+  color: #1f2a44;
+  font-size: 14px;
+}
+
+.ss-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 24px;
+  padding: 12px 28px;
+  background: #fff;
+  border-bottom: 1px solid #e8edf5;
+
+  &__title {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+
+    b {
+      font-size: 18px;
+    }
+
+    span {
+      font-size: 12px;
+      color: #8a94a6;
+    }
+  }
+}
+
+.ss-steps {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.ss-step {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 10px;
+  border-radius: 10px;
+  color: #8a94a6;
+  user-select: none;
+
+  &__no {
+    width: 28px;
+    height: 28px;
+    flex: none;
+    border-radius: 50%;
+    border: 2px solid #d3d9e6;
+    background: #fff;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 13px;
+    font-weight: 600;
+  }
+
+  &__text {
+    display: flex;
+    flex-direction: column;
+    line-height: 1.35;
+
+    b {
+      font-size: 14px;
+    }
+
+    small {
+      font-size: 12px;
+    }
+  }
+
+  &__line {
+    width: 36px;
+    height: 2px;
+    border-radius: 1px;
+    background: #e1e6f0;
+
+    &.is-done {
+      background: #2957ff;
+    }
+  }
+
+  &.is-active {
+    color: #1f2a44;
+
+    .ss-step__no {
+      border-color: #2957ff;
+      background: #2957ff;
+      color: #fff;
+    }
+
+    small {
+      color: #5b6b8c;
+    }
+  }
+
+  &.is-done {
+    color: #1f2a44;
+
+    .ss-step__no {
+      border-color: #2957ff;
+      color: #2957ff;
+    }
+  }
+
+  &.is-clickable {
+    cursor: pointer;
+
+    &:hover {
+      background: #f0f4ff;
+    }
+  }
+}
+
+.ss-main {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.ss-inner {
+  max-width: 1120px;
+  margin: 0 auto;
+  padding: 20px 24px 28px;
+}
+
+.ss-stack {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.ss-card {
+  background: #fff;
+  border: 1px solid #e8edf5;
+  border-radius: 14px;
+  padding: 20px 22px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+
+  &__head {
+    position: relative;
+    padding-left: 12px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 3px;
+      width: 4px;
+      height: 16px;
+      border-radius: 2px;
+      background: #2957ff;
+    }
+
+    &--row {
+      display: flex;
+      align-items: flex-start;
+      gap: 16px;
+    }
+  }
+
+  &__grow {
+    flex: 1;
+    min-width: 0;
+  }
+
+  &__title {
+    font-size: 16px;
+    font-weight: 600;
+    line-height: 22px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  &__desc {
+    margin-top: 4px;
+    font-size: 13px;
+    color: #6b778c;
+    line-height: 1.6;
+  }
+}
+
+.ss-muted {
+  color: #8a94a6;
+  font-size: 12px;
+  font-weight: normal;
+  line-height: 1.6;
+}
+
+.ss-inline {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.ss-optional {
+  font-size: 12px;
+  font-weight: normal;
+  color: #8a94a6;
+}
+
+.ss-pill {
+  display: inline-flex;
+  align-items: center;
+  height: 32px;
+  padding: 0 14px;
+  border-radius: 16px;
+  background: #f2f4f8;
+  color: #4a5875;
+  font-size: 13px;
+  white-space: nowrap;
+
+  &--blue {
+    height: 20px;
+    padding: 0 8px;
+    border-radius: 10px;
+    background: #eef2ff;
+    color: #2957ff;
+    font-size: 12px;
+    font-weight: 500;
+  }
+}
+
+.alert-body {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  line-height: 1.6;
+}
+
+.alert-raw {
+  color: #8a94a6;
+}
+
+.alert-actions {
+  display: flex;
+  gap: 8px;
+  margin-top: 2px;
+}
+
+.goods-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+
+  &__search {
+    width: 380px;
+  }
+
+  &__category {
+    width: 200px;
+  }
+}
+
+.goods-ok {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  color: #2f9d62;
+  font-size: 13px;
+}
+
+.tier-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+}
+
+.tier-card {
+  border: 1.5px solid #e1e6f0;
+  border-radius: 12px;
+  padding: 14px 16px;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  transition: border-color 0.15s, background-color 0.15s;
+
+  &:hover {
+    border-color: #a9bbff;
+  }
+
+  &.is-on {
+    border-color: #2957ff;
+    background: #f5f8ff;
+    box-shadow: 0 0 0 3px rgba(41, 87, 255, 0.08);
+  }
+
+  &__top {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  &__badge {
+    width: 26px;
+    height: 26px;
+    border-radius: 8px;
+    background: #f2f4f8;
+    color: #4a5875;
+    font-weight: 700;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &.is-on &__badge {
+    background: #2957ff;
+    color: #fff;
+  }
+
+  &__name {
+    font-size: 15px;
+    font-weight: 600;
+  }
+
+  &__range {
+    font-size: 12px;
+    color: #6b778c;
+  }
+
+  &__desc {
+    font-size: 13px;
+    color: #4a5875;
+  }
+
+  &__scenes {
+    font-size: 12px;
+    color: #8a94a6;
+    line-height: 1.6;
+  }
+}
+
+.height-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  font-size: 13px;
+  color: #4a5875;
+
+  &__input {
+    width: 150px;
+  }
+}
+
+.layout-switch {
+  flex: none;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  color: #6b778c;
+}
+
+.cutout-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+  gap: 12px;
+}
+
+.cutout-card {
+  border: 1.5px solid #e8edf5;
+  border-radius: 12px;
+  padding: 8px;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  transition: border-color 0.15s;
+
+  &:hover {
+    border-color: #a9bbff;
+  }
+
+  &.is-editing {
+    border-color: #2957ff;
+    box-shadow: 0 0 0 3px rgba(41, 87, 255, 0.08);
+  }
+
+  &.is-off img {
+    opacity: 0.4;
+  }
+
+  &__img {
+    position: relative;
+    aspect-ratio: 1;
+    border-radius: 8px;
+    overflow: hidden;
+    background: repeating-conic-gradient(#eceff5 0% 25%, #f7f8fb 0% 50%) 50% / 16px 16px;
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+      transition: transform 0.2s;
+    }
+  }
+
+  &__index {
+    position: absolute;
+    left: 6px;
+    top: 6px;
+    min-width: 20px;
+    height: 20px;
+    padding: 0 6px;
+    border-radius: 10px;
+    background: rgba(31, 42, 68, 0.72);
+    color: #fff;
+    font-size: 12px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &__name {
+    font-size: 13px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+
+    em {
+      font-style: normal;
+      font-size: 11px;
+      font-weight: normal;
+      color: #d14343;
+    }
+  }
+
+  &__tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px;
+    min-height: 20px;
+  }
+}
+
+.cut-tag {
+  display: inline-flex;
+  align-items: center;
+  height: 20px;
+  padding: 0 6px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-style: normal;
+  white-space: nowrap;
+
+  &--plate {
+    background: #2957ff;
+    color: #fff;
+  }
+
+  &--view {
+    background: #e8f6ee;
+    color: #2f9d62;
+  }
+
+  &--detail {
+    background: #fff4e5;
+    color: #c77700;
+  }
+
+  &--off {
+    background: #f2f4f8;
+    color: #8a94a6;
+  }
+}
+
+.cutout-edit {
+  border: 1px solid #d6e0ff;
+  background: #f7f9ff;
+  border-radius: 12px;
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+
+  &__title {
+    font-size: 13px;
+    font-weight: 600;
+  }
+
+  &__row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+  }
+
+  &__label {
+    font-size: 13px;
+    color: #6b778c;
+  }
+
+  &__select {
+    width: 160px;
+  }
+
+  &__sep {
+    width: 1px;
+    height: 18px;
+    background: #dde3ee;
+  }
+
+  &__done {
+    margin-left: auto;
+  }
+}
+
+.cutout-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 20px;
+  font-size: 12px;
+  color: #6b778c;
+
+  span {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+  }
+}
+
+.basis-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+
+.basis-card {
+  border: 1.5px solid #e1e6f0;
+  border-radius: 12px;
+  padding: 14px 16px;
+  cursor: pointer;
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  transition: border-color 0.15s, background-color 0.15s;
+
+  &:hover {
+    border-color: #a9bbff;
+  }
+
+  &.is-on {
+    border-color: #2957ff;
+    background: #f5f8ff;
+  }
+
+  &__radio {
+    position: relative;
+    flex: none;
+    width: 16px;
+    height: 16px;
+    margin-top: 3px;
+    border-radius: 50%;
+    border: 1.5px solid #c3cbdb;
+    background: #fff;
+  }
+
+  &.is-on &__radio {
+    border-color: #2957ff;
+
+    &::after {
+      content: '';
+      position: absolute;
+      inset: 3px;
+      border-radius: 50%;
+      background: #2957ff;
+    }
+  }
+
+  &__title {
+    font-size: 15px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  &__desc {
+    margin-top: 4px;
+    font-size: 13px;
+    color: #6b778c;
+    line-height: 1.6;
+  }
+}
+
+.scene-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px;
+}
+
+.scene-card {
+  position: relative;
+  border: 1.5px solid #e1e6f0;
+  border-radius: 10px;
+  padding: 12px 14px 12px 40px;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+
+  &:hover {
+    border-color: #a9bbff;
+  }
+
+  &.is-on {
+    border-color: #2957ff;
+    background: #f5f8ff;
+  }
+
+  &__check {
+    position: absolute;
+    left: 12px;
+    top: 13px;
+    width: 18px;
+    height: 18px;
+    border-radius: 5px;
+    border: 1.5px solid #c3cbdb;
+    background: #fff;
+    color: transparent;
+    font-size: 12px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &.is-on &__check {
+    background: #2957ff;
+    border-color: #2957ff;
+    color: #fff;
+  }
+
+  &__name {
+    font-size: 14px;
+    font-weight: 600;
+  }
+
+  &__sub {
+    font-size: 12px;
+    color: #6b778c;
+  }
+
+  &__need {
+    font-size: 12px;
+    color: #c77700;
+  }
+}
+
+.scene-pool {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+}
+
+.pool-chip {
+  display: inline-flex;
+  align-items: center;
+  height: 26px;
+  padding: 0 10px;
+  border-radius: 13px;
+  background: #f2f4f8;
+  color: #4a5875;
+  font-size: 12px;
+
+  em {
+    font-style: normal;
+    color: #c77700;
+  }
+}
+
+.visible-box {
+  border: 1px dashed #d6dce8;
+  border-radius: 10px;
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+
+  &__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    font-size: 13px;
+    color: #4a5875;
+  }
+}
+
+.chip-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  height: 30px;
+  padding: 0 14px;
+  border-radius: 15px;
+  border: 1px solid #dde3ee;
+  background: #fff;
+  color: #4a5875;
+  font-size: 13px;
+  cursor: pointer;
+  user-select: none;
+
+  &:hover {
+    border-color: #a9bbff;
+  }
+
+  &.is-on {
+    border-color: #2957ff;
+    background: #eef2ff;
+    color: #2957ff;
+  }
+}
+
+.more {
+  border-top: none;
+  border-bottom: none;
+
+  :deep(.el-collapse-item__header) {
+    height: 40px;
+    gap: 8px;
+    border-bottom: none;
+    font-size: 14px;
+  }
+
+  :deep(.el-collapse-item__wrap) {
+    border-bottom: none;
+  }
+
+  :deep(.el-collapse-item__content) {
+    padding-bottom: 4px;
+  }
+
+  &__title {
+    font-weight: 600;
+    color: #1f2a44;
+  }
+}
+
+.more-group {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+
+  & + & {
+    margin-top: 16px;
+  }
+
+  &__title {
+    font-size: 13px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+}
+
+.material-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px 12px;
+}
+
+.material-item {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 12px;
+  color: #6b778c;
+
+  &__value {
+    height: 24px;
+    display: flex;
+    align-items: center;
+    color: #4a5875;
+  }
+}
+
+.excel-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+
+  &__name {
+    max-width: 420px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 13px;
+    color: #4a5875;
+  }
+}
+
+.anchor-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14px;
+}
+
+.anchor-card {
+  width: 100px;
+  border: 1.5px solid #e1e6f0;
+  border-radius: 12px;
+  padding: 10px;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+
+  &:hover {
+    border-color: #a9bbff;
+  }
+
+  &.is-on {
+    border-color: #2957ff;
+    background: #f5f8ff;
+  }
+
+  &__label {
+    font-size: 13px;
+  }
+}
+
+.anchor-frame {
+  position: relative;
+  width: 64px;
+  height: 96px;
+  border-radius: 6px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #e3eaf6 0%, #cdd7ea 62%, #b7c2d8 100%);
+
+  &__goods {
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    border-radius: 4px;
+    background: #4a5875;
+
+    &.is-handheld {
+      width: 66%;
+      height: 20%;
+      bottom: 10%;
+    }
+
+    &.is-floor {
+      width: 42%;
+      height: 44%;
+      bottom: 2%;
+    }
+  }
+
+  &__text {
+    position: absolute;
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+
+    i {
+      display: block;
+      width: 30px;
+      height: 5px;
+      border-radius: 2px;
+      background: #2957ff;
+    }
+
+    i + i {
+      width: 20px;
+      height: 4px;
+      opacity: 0.55;
+    }
+
+    &.at-top-left {
+      top: 7%;
+      left: 8%;
+      align-items: flex-start;
+    }
+
+    &.at-top-right {
+      top: 7%;
+      right: 8%;
+      align-items: flex-end;
+    }
+
+    &.at-bottom-left {
+      bottom: 6%;
+      left: 8%;
+      align-items: flex-start;
+    }
+
+    &.at-bottom-right {
+      bottom: 6%;
+      right: 8%;
+      align-items: flex-end;
+    }
+
+    &.at-center {
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      align-items: center;
+    }
+  }
+}
+
+.anchor-card.is-on .anchor-frame__text i {
+  background: #ff7a00;
+}
+
+.confirm-list {
+  display: flex;
+  flex-direction: column;
+  border: 1px solid #edf1f7;
+  border-radius: 12px;
+  overflow: hidden;
+}
+
+.confirm-row {
+  display: flex;
+  gap: 16px;
+  padding: 12px 16px;
+  font-size: 14px;
+
+  & + & {
+    border-top: 1px solid #edf1f7;
+  }
+
+  span {
+    flex: none;
+    width: 72px;
+    color: #8a94a6;
+  }
+
+  b {
+    flex: 1;
+    font-weight: 500;
+    line-height: 1.6;
+  }
+
+  small {
+    display: block;
+    margin-top: 2px;
+    font-size: 12px;
+    font-weight: normal;
+    color: #8a94a6;
+  }
+
+  &--coin b {
+    color: #2957ff;
+    font-weight: 600;
+  }
+}
+
+.run-head {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+
+  &__icon {
+    flex: none;
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 20px;
+
+    &.is-run {
+      background: #eef2ff;
+      color: #2957ff;
+    }
+
+    &.is-ok {
+      background: #e8f6ee;
+      color: #2f9d62;
+    }
+
+    &.is-warn {
+      background: #fff4e5;
+      color: #c77700;
+    }
+
+    &.is-bad {
+      background: #fdecec;
+      color: #d14343;
+    }
+  }
+
+  &__text {
+    flex: 1;
+    min-width: 0;
+  }
+
+  &__title {
+    font-size: 17px;
+    font-weight: 600;
+    line-height: 24px;
+  }
+
+  &__desc {
+    margin-top: 2px;
+    font-size: 13px;
+    color: #6b778c;
+    line-height: 1.6;
+    word-break: break-all;
+  }
+}
+
+.run-grid,
+.result-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+  gap: 12px;
+}
+
+.run-card,
+.result-card {
+  border: 1px solid #e8edf5;
+  border-radius: 10px;
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.run-card {
+  &__img {
+    aspect-ratio: 2 / 3;
+    border-radius: 6px;
+    overflow: hidden;
+    background: #f2f4f8;
+
+    :deep(.el-image) {
+      width: 100%;
+      height: 100%;
+      display: block;
+      cursor: zoom-in;
+    }
+  }
+
+  &__placeholder {
+    width: 100%;
+    height: 100%;
+    padding: 10px;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    text-align: center;
+    font-size: 12px;
+    color: #8a94a6;
+
+    .el-icon {
+      font-size: 20px;
+      color: #2957ff;
+    }
+
+    &.is-bad {
+      color: #d14343;
+    }
+  }
+
+  &__name {
+    font-size: 13px;
+    font-weight: 600;
+  }
+
+  &__status {
+    font-size: 12px;
+
+    &.is-ok {
+      color: #2f9d62;
+    }
+
+    &.is-bad {
+      color: #d14343;
+    }
+
+    &.is-local,
+    &.is-wait {
+      color: #8a94a6;
+    }
+  }
+}
+
+.result-card {
+  :deep(.el-image) {
+    width: 100%;
+    aspect-ratio: 2 / 3;
+    display: block;
+    border-radius: 6px;
+    background: #f2f4f8;
+    cursor: zoom-in;
+  }
+
+  &__name {
+    font-size: 12px;
+    color: #4a5875;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.run-notes {
+  border-radius: 10px;
+  background: #f7f8fb;
+  padding: 12px 14px;
+  font-size: 12px;
+  color: #6b778c;
+  line-height: 1.8;
+
+  &__title {
+    font-size: 13px;
+    font-weight: 600;
+    color: #4a5875;
+    margin-bottom: 2px;
+  }
+}
+
+.ss-footer {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 12px 28px;
+  background: #fff;
+  border-top: 1px solid #e8edf5;
+
+  &__side {
+    flex: none;
+    min-width: 120px;
+    display: flex;
+    gap: 8px;
+
+    &--right {
+      justify-content: flex-end;
+    }
+  }
+
+  &__info {
+    flex: 1;
+    min-width: 0;
+    text-align: center;
+    font-size: 13px;
+    color: #6b778c;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__warn {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    color: #c77700;
+  }
+}
+</style>

+ 81 - 0
frontend/src/views/Photography/sceneSet/productScene.json

@@ -0,0 +1,81 @@
+{
+  "copy_anchors": [
+    { "value": "top-left", "label": "左上" },
+    { "value": "top-right", "label": "右上" },
+    { "value": "bottom-left", "label": "左下" },
+    { "value": "bottom-right", "label": "右下" },
+    { "value": "center", "label": "中间" }
+  ],
+  "layouts": [
+    { "value": "handheld", "label": "横放扁手提", "height_ratio": 0.3, "bottom_ratio": 0.1, "max_width_ratio": 0.66 },
+    { "value": "floor", "label": "落地", "height_ratio": 0.44, "bottom_ratio": 0.02, "max_width_ratio": 0.9 }
+  ],
+  "categories": [
+    {
+      "id": 1,
+      "name": "音箱",
+      "code": "speaker",
+      "company_id": 0,
+      "scene_coin": 2,
+      "analyze_coin": 1,
+      "max_scene_count": 8,
+      "default_scene_count": 6,
+      "selling_tags": ["灯效", "防水", "续航", "低音", "滚轮", "K歌", "便携", "提手", "蓝牙", "户外", "居家", "派对"],
+      "visible_features": [
+        { "key": "brand", "label": "品牌", "tag": "" },
+        { "key": "horizontal", "label": "横放造型", "tag": "便携" },
+        { "key": "handle", "label": "顶部提手", "tag": "提手" },
+        { "key": "orange_ends", "label": "橙色两端", "tag": "" },
+        { "key": "black_fabric", "label": "黑色织布", "tag": "" },
+        { "key": "top_buttons", "label": "顶面按键", "tag": "" }
+      ],
+      "material_fields": ["卖点", "高度", "宽度", "深度", "重量", "续航", "蓝牙", "功率", "防水"],
+      "slots": [
+        { "id": 1, "layout": "handheld", "seq": 1, "name": "端面", "role": "end", "rotation": 0, "use_long_scene": 0, "use_three_view": 0, "use_detail": 1, "use_for_scene": 0, "crops": [] },
+        { "id": 2, "layout": "handheld", "seq": 2, "name": "斜前", "role": "hero", "rotation": 0, "use_long_scene": 1, "use_three_view": 0, "use_detail": 0, "use_for_scene": 1, "crops": [] },
+        { "id": 3, "layout": "handheld", "seq": 3, "name": "斜侧", "role": "side", "rotation": 0, "use_long_scene": 0, "use_three_view": 1, "use_detail": 0, "use_for_scene": 1, "crops": [] },
+        {
+          "id": 4,
+          "layout": "handheld",
+          "seq": 4,
+          "name": "顶面",
+          "role": "top",
+          "rotation": 180,
+          "use_long_scene": 0,
+          "use_three_view": 1,
+          "use_detail": 1,
+          "use_for_scene": 0,
+          "crops": [
+            { "label": "提手", "box": [0.2, 0, 0.8, 0.38] },
+            { "label": "顶面按键", "box": [0.15, 0.35, 0.85, 0.85] }
+          ]
+        },
+        { "id": 5, "layout": "handheld", "seq": 5, "name": "背面", "role": "back", "rotation": 0, "use_long_scene": 0, "use_three_view": 0, "use_detail": 0, "use_for_scene": 0, "crops": [] },
+        { "id": 6, "layout": "handheld", "seq": 6, "name": "正面", "role": "front", "rotation": 0, "use_long_scene": 1, "use_three_view": 1, "use_detail": 0, "use_for_scene": 1, "crops": [] },
+        { "id": 7, "layout": "floor", "seq": 1, "name": "正面", "role": "front", "rotation": 0, "use_long_scene": 1, "use_three_view": 1, "use_detail": 0, "use_for_scene": 1, "crops": [] },
+        { "id": 8, "layout": "floor", "seq": 2, "name": "45度", "role": "angle45", "rotation": 0, "use_long_scene": 1, "use_three_view": 0, "use_detail": 0, "use_for_scene": 1, "crops": [] },
+        { "id": 9, "layout": "floor", "seq": 3, "name": "背面", "role": "back", "rotation": 0, "use_long_scene": 0, "use_three_view": 0, "use_detail": 0, "use_for_scene": 0, "crops": [] }
+      ],
+      "scenes": [
+        { "id": 1, "tier": "L", "tag": "dusk_camp", "name": "黄昏营地", "expresses": ["户外", "派对", "低音"], "requires": [], "title": "营地里 / 先把歌打开", "is_custom": 0 },
+        { "id": 2, "tier": "L", "tag": "living_floor", "name": "客厅地板", "expresses": ["居家", "低音"], "requires": [], "title": "地板上 / 先把歌打开", "is_custom": 0 },
+        { "id": 3, "tier": "L", "tag": "night_yard", "name": "夜晚庭院", "expresses": ["户外", "灯效", "派对"], "requires": [], "title": "庭院里 / 先把歌打开", "is_custom": 0 },
+        { "id": 4, "tier": "L", "tag": "seaside_path", "name": "海边步道", "expresses": ["户外", "便携", "滚轮", "提手"], "requires": ["提手", "滚轮"], "title": "海边 / 先把歌打开", "is_custom": 0 },
+        { "id": 5, "tier": "L", "tag": "day_beach", "name": "白天沙滩", "expresses": ["户外", "派对"], "requires": [], "title": "沙地上 / 先把歌打开", "is_custom": 0 },
+        { "id": 6, "tier": "L", "tag": "sand_picnic", "name": "沙地野餐远景", "expresses": ["户外", "派对"], "requires": [], "title": "野餐垫外 / 先把歌打开", "is_custom": 0 },
+        { "id": 8, "tier": "M", "tag": "camp_table", "name": "营地木桌", "expresses": ["户外", "便携"], "requires": [], "title": "木桌上 / 先把歌打开", "is_custom": 0 },
+        { "id": 9, "tier": "M", "tag": "living_side", "name": "客厅边柜", "expresses": ["居家"], "requires": [], "title": "边柜上 / 先把歌打开", "is_custom": 0 },
+        { "id": 10, "tier": "M", "tag": "night_yard_table", "name": "夜晚庭院长桌", "expresses": ["户外", "灯效"], "requires": [], "title": "长桌上 / 先把歌打开", "is_custom": 0 },
+        { "id": 11, "tier": "M", "tag": "park_bench", "name": "公园长椅", "expresses": ["便携", "户外", "提手"], "requires": ["提手"], "title": "长椅上 / 先把歌打开", "is_custom": 0 },
+        { "id": 12, "tier": "M", "tag": "day_beach", "name": "白天沙滩", "expresses": ["户外", "派对"], "requires": [], "title": "沙地上 / 先把歌打开", "is_custom": 0 },
+        { "id": 13, "tier": "M", "tag": "poolside", "name": "泳池边", "expresses": ["防水", "户外"], "requires": ["防水"], "title": "池边 / 先把歌打开", "is_custom": 0 },
+        { "id": 15, "tier": "S", "tag": "desk", "name": "书桌", "expresses": ["居家", "便携"], "requires": [], "title": "书桌上 / 先把歌打开", "is_custom": 0 },
+        { "id": 16, "tier": "S", "tag": "coffee", "name": "咖啡桌", "expresses": ["居家"], "requires": [], "title": "咖啡桌上 / 先把歌打开", "is_custom": 0 },
+        { "id": 17, "tier": "S", "tag": "picnic_mat", "name": "野餐垫", "expresses": ["户外", "便携"], "requires": [], "title": "野餐垫上 / 先把歌打开", "is_custom": 0 },
+        { "id": 18, "tier": "S", "tag": "beach_towel", "name": "沙滩毛巾", "expresses": ["户外"], "requires": [], "title": "毛巾上 / 先把歌打开", "is_custom": 0 },
+        { "id": 19, "tier": "S", "tag": "night_yard_wood", "name": "夜晚庭院木桌", "expresses": ["户外", "灯效"], "requires": [], "title": "木桌上 / 先把歌打开", "is_custom": 0 },
+        { "id": 20, "tier": "S", "tag": "poolside", "name": "泳池边", "expresses": ["防水", "户外"], "requires": ["防水"], "title": "池边 / 先把歌打开", "is_custom": 0 }
+      ]
+    }
+  ]
+}

+ 1 - 1
public/dist/index.html

@@ -5,7 +5,7 @@
     <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-BzDVIlAS.js"></script>
+    <script type="module" crossorigin src="./assets/index-ClELuh0h.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-CUtW5LTp.css">
   </head>
   <body>

+ 26 - 0
python/api.py

@@ -2512,3 +2512,29 @@ async def shoeCompose(params: ShoesComposes):
     print("response", response.content)
     responseData = response.json()
     return {"code": 0, "msg": "操作成功", "data": responseData.get("data", None)}
+
+
+def _scene_set_result(func, params):
+    try:
+        return {"code": 0, "msg": "操作成功", "data": func(params.model_dump())}
+    except Exception as error:
+        logger.exception("scene_set")
+        return {"code": 1, "msg": str(error), "data": None}
+
+
+@app.post("/scene_set/prepare", description="卖点套图读取已有抠图")
+def scene_set_prepare(params: SceneSetPrepare):
+    from service.scene_set import prepare_scene_set
+    return _scene_set_result(prepare_scene_set, params)
+
+
+@app.post("/scene_set/run", description="卖点套图创建任务")
+def scene_set_run(params: SceneSetRun):
+    from service.scene_set import run_scene_set
+    return _scene_set_result(run_scene_set, params)
+
+
+@app.post("/scene_set/save", description="卖点套图保存到货号目录")
+def scene_set_save(params: SceneSetSave):
+    from service.scene_set import save_scene_set
+    return _scene_set_result(save_scene_set, params)

+ 35 - 0
python/models.py

@@ -246,3 +246,38 @@ class ShoesComposeItem(BaseModel):
 class ShoesComposes(BaseModel):
     """拍照机商品资料上传"""
     data: list[ShoesComposeItem] = Field(default=None, description="结构体")
+
+
+class SceneSetPrepare(BaseModel):
+    goods_art_no: str = ""
+    image_path: str = ""
+    goods_dir: str = ""
+    layout: str = "handheld"
+    slots: list = Field(default_factory=list)
+
+
+class SceneSetRun(BaseModel):
+    goods_art_no: str = ""
+    image_path: str = ""
+    goods_dir: str = ""
+    layout: str = "handheld"
+    basis: str = "selling_point"
+    copy_anchor: str = "top-left"
+    category_id: int = 0
+    scene_tags: list = Field(default_factory=list)
+    selling_points: str = ""
+    materials: dict = Field(default_factory=dict)
+    visible_features: list = Field(default_factory=list)
+    height_cm: str = ""
+    missing_height: str = "skip"
+    default_tier: str = "M"
+    cutouts: list = Field(default_factory=list)
+    excel_path: str = ""
+    force: int = 0
+
+
+class SceneSetSave(BaseModel):
+    goods_dir: str = ""
+    copy_anchor: str = "top-left"
+    job: dict = Field(default_factory=dict)
+    cutouts: list = Field(default_factory=list)

+ 647 - 0
python/service/scene_set.py

@@ -0,0 +1,647 @@
+import json
+import os
+import re
+
+import pandas as pd
+import requests
+from natsort.natsort import natsorted
+from PIL import Image, ImageDraw, ImageFont
+
+import settings
+from logger import logger
+
+PLATE_LONG = 2048
+PLATE_SIZE = (1366, 2048)
+PLATE_COLOR = (186, 186, 186)
+OUTPUT_NAME = "场景套图"
+MARKER_NAME = "场景套图_任务.json"
+TEMP_NAME = "_场景套图临时"
+LAYOUT_PLATE = {
+    "handheld": {"height_ratio": 0.30, "bottom_ratio": 0.10, "max_width_ratio": 0.66},
+    "floor": {"height_ratio": 0.44, "bottom_ratio": 0.02, "max_width_ratio": 0.90},
+}
+ANCHOR_LABELS = {
+    "top-left": "左上",
+    "top-right": "右上",
+    "bottom-left": "左下",
+    "bottom-right": "右下",
+    "center": "中间",
+}
+SKIP_FOLDERS = {"原始图", "原始图_已抠图", "800x800", "阴影图处理", "200images", OUTPUT_NAME, TEMP_NAME}
+
+
+def prepare_scene_set(payload):
+    goods_dir = resolve_goods_dir(payload)
+    cutout_dir = os.path.join(goods_dir, "原始图_已抠图")
+    if not os.path.isdir(cutout_dir):
+        raise RuntimeError("没有找到原始图_已抠图,不会重新抠图")
+    files = natsorted([
+        name for name in os.listdir(cutout_dir)
+        if name.lower().endswith(".png") and os.path.isfile(os.path.join(cutout_dir, name))
+    ])
+    if not files:
+        raise RuntimeError("原始图_已抠图里没有 PNG,不会重新抠图")
+    layout = payload.get("layout") or "handheld"
+    slots = [slot for slot in (payload.get("slots") or []) if (slot.get("layout") or layout) == layout]
+    slots = sorted(slots, key=lambda slot: int(slot.get("seq") or 0))
+    cutouts = []
+    for index, name in enumerate(files):
+        slot = slots[index] if index < len(slots) else {}
+        cutouts.append({
+            "file": name,
+            "path": os.path.join(cutout_dir, name),
+            "index": index + 1,
+            "slot_id": slot.get("id") or 0,
+            "name": slot.get("name") or "未识别",
+            "role": slot.get("role") or "",
+            "rotation": int(slot.get("rotation") or 0),
+            "use_long_scene": int(slot.get("use_long_scene") or 0),
+            "use_three_view": int(slot.get("use_three_view") or 0),
+            "use_detail": int(slot.get("use_detail") or 0),
+            "use_for_scene": int(slot.get("use_for_scene") or 0) if slot else 0,
+            "crops": slot.get("crops") or [],
+        })
+    output_dir = os.path.join(goods_dir, OUTPUT_NAME)
+    logger.info("场景套图使用已有抠图 goods=%s dir=%s count=%s", payload.get("goods_art_no"), goods_dir, len(cutouts))
+    return {
+        "goods_dir": goods_dir,
+        "cutout_dir": cutout_dir,
+        "output_dir": output_dir,
+        "output_exists": os.path.isdir(output_dir),
+        "cutouts": cutouts,
+        "message": "使用已有抠图,没有重新抠图",
+    }
+
+
+def run_scene_set(payload):
+    goods_dir = resolve_goods_dir(payload)
+    output_dir = os.path.join(goods_dir, OUTPUT_NAME)
+    if os.path.isdir(output_dir):
+        return {
+            "skipped": True,
+            "reason": "已有场景套图目录,要重出请先删除该目录",
+            "output_dir": output_dir,
+            "goods_dir": goods_dir,
+        }
+    marker = os.path.join(goods_dir, MARKER_NAME)
+    if os.path.isfile(marker) and not int(payload.get("force") or 0):
+        saved = json.loads(open(marker, "r", encoding="utf-8").read())
+        if saved.get("job_id"):
+            return {
+                "resumed": True,
+                "job_id": saved["job_id"],
+                "goods_dir": goods_dir,
+                "output_dir": output_dir,
+                "reason": "继续上次未完成的任务",
+            }
+    cutouts = payload.get("cutouts") or []
+    source = next((item for item in cutouts if int(item.get("use_for_scene") or 0) and int(item.get("use_long_scene") or 0)), None)
+    if not source:
+        raise RuntimeError("没有可用于场景的机位")
+    layout = payload.get("layout") or "handheld"
+    temp_dir = os.path.join(goods_dir, TEMP_NAME)
+    os.makedirs(temp_dir, exist_ok=True)
+    plate_path = os.path.join(temp_dir, "plate.jpg")
+    png_path = os.path.join(temp_dir, "cutout.png")
+    build_plate(source["path"], int(source.get("rotation") or 0), layout, png_path, plate_path)
+    logger.info("场景套图上传抠图 PNG,不转成 JPEG %s", png_path)
+    upload_file(png_path)
+    plate_url = upload_file(plate_path)
+    materials = collect_materials(payload)
+    body = {
+        "category_id": int(payload.get("category_id") or 0),
+        "goods_art_no": payload.get("goods_art_no") or "",
+        "basis": payload.get("basis") or "selling_point",
+        "scene_tags": payload.get("scene_tags") or [],
+        "materials": materials,
+        "cutouts": [public_cutout(item) for item in cutouts],
+        "copy_anchor": payload.get("copy_anchor") or "top-left",
+        "plate_url": plate_url,
+        "options": {
+            "layout": layout,
+            "height_cm": payload.get("height_cm") or "",
+            "missing_height": payload.get("missing_height") or "skip",
+            "default_tier": payload.get("default_tier") or "M",
+            "visible_features": payload.get("visible_features") or [],
+        },
+    }
+    data = backend_post("/product_scene/create_job", body)
+    if data.get("job_id"):
+        with open(marker, "w", encoding="utf-8") as file:
+            json.dump({"job_id": data["job_id"]}, file, ensure_ascii=False)
+    data["goods_dir"] = goods_dir
+    data["output_dir"] = output_dir
+    data["message"] = "使用已有抠图,没有重新抠图"
+    return data
+
+
+def save_scene_set(payload):
+    goods_dir = payload.get("goods_dir") or ""
+    if not goods_dir or not os.path.isdir(goods_dir):
+        raise RuntimeError("货号目录不存在")
+    job = payload.get("job") or {}
+    cutouts = payload.get("cutouts") or []
+    anchor = job.get("copy_anchor") or payload.get("copy_anchor") or "top-left"
+    output_dir = os.path.join(goods_dir, OUTPUT_NAME)
+    os.makedirs(output_dir, exist_ok=True)
+    saved = []
+    notes = list(job.get("progress_notes") or [])
+    for image in job.get("images") or []:
+        kind = image.get("kind")
+        if kind == "scene":
+            if int(image.get("status") or 0) != 2 or not image.get("result_url"):
+                notes.append((image.get("scene_name") or "场景") + " 未出图:" + (image.get("error_msg") or "失败"))
+                continue
+            stem = "场景_{:02d}_{}".format(int(image.get("sort") or 1), safe_name(image.get("scene_name") or "场景"))
+            jpg_path = os.path.join(output_dir, stem + ".jpg")
+            download(image["result_url"], jpg_path)
+            png_path = os.path.join(output_dir, stem + ".png")
+            compose_copy(Image.open(jpg_path).convert("RGB"), anchor, image.get("title") or "", image.get("lines") or []).save(png_path, "PNG")
+            saved.append(jpg_path)
+            saved.append(png_path)
+        elif kind == "three_view":
+            path = os.path.join(output_dir, "三视图.png")
+            render_three_view(cutouts, image, anchor).save(path, "PNG")
+            saved.append(path)
+        elif kind == "detail":
+            path = os.path.join(output_dir, "细节.png")
+            render_detail(cutouts, image, anchor).save(path, "PNG")
+            saved.append(path)
+        elif kind in ("size", "param"):
+            filename = "尺寸.png" if kind == "size" else "参数.png"
+            path = os.path.join(output_dir, filename)
+            render_text_page(image.get("title") or filename[:-4], image.get("lines") or [], anchor).save(path, "PNG")
+            saved.append(path)
+    txt_path = os.path.join(output_dir, "卖点.txt")
+    with open(txt_path, "w", encoding="utf-8") as file:
+        file.write(selling_text(job, anchor, notes))
+    saved.append(txt_path)
+    marker = os.path.join(goods_dir, MARKER_NAME)
+    if os.path.isfile(marker):
+        os.remove(marker)
+    logger.info("场景套图已写入 %s", output_dir)
+    return {"output_dir": output_dir, "files": saved, "notes": notes}
+
+
+def resolve_goods_dir(payload):
+    goods_dir = payload.get("goods_dir") or ""
+    if goods_dir and os.path.isdir(goods_dir) and has_cutout_png(goods_dir):
+        return os.path.abspath(goods_dir)
+    image_dir = goods_dir_from_image(payload.get("image_path") or "")
+    if image_dir and has_cutout_png(image_dir):
+        return image_dir
+    found = find_output_goods_dir(payload.get("goods_art_no") or "")
+    if found:
+        return found
+    if image_dir:
+        return image_dir
+    if goods_dir and os.path.isdir(goods_dir):
+        return os.path.abspath(goods_dir)
+    raise RuntimeError("没有找到货号目录")
+
+
+def goods_dir_from_image(image_path):
+    if not image_path or not os.path.exists(image_path):
+        return ""
+    parent = os.path.dirname(os.path.abspath(image_path))
+    if os.path.basename(parent) in SKIP_FOLDERS:
+        parent = os.path.dirname(parent)
+    return parent
+
+
+def has_cutout_png(goods_dir):
+    cutout_dir = os.path.join(goods_dir, "原始图_已抠图")
+    if not os.path.isdir(cutout_dir):
+        return False
+    for name in os.listdir(cutout_dir):
+        path = os.path.join(cutout_dir, name)
+        if name.lower().endswith(".png") and os.path.isfile(path):
+            return True
+    return False
+
+
+def find_output_goods_dir(goods_art_no):
+    goods_art_no = str(goods_art_no or "").strip()
+    output_dir = str(settings.OUTPUT_DIR)
+    if not goods_art_no or not os.path.isdir(output_dir):
+        return ""
+    matches = []
+    try:
+        names = os.listdir(output_dir)
+    except OSError:
+        return ""
+    for date_dir in names:
+        date_path = os.path.join(output_dir, date_dir)
+        goods_path = os.path.join(date_path, goods_art_no)
+        if os.path.isdir(date_path) and has_cutout_png(goods_path):
+            matches.append(os.path.abspath(goods_path))
+    if not matches:
+        return ""
+    matches.sort(key=lambda path: os.path.basename(os.path.dirname(path)))
+    return matches[-1]
+
+
+def build_plate(source_path, rotation, layout, png_path, plate_path):
+    image = Image.open(source_path).convert("RGBA")
+    if rotation:
+        image = image.rotate(-int(rotation), expand=True)
+    image = trim_alpha(image)
+    image = fit_long_edge(image, PLATE_LONG)
+    image.save(png_path, "PNG")
+    ratios = LAYOUT_PLATE.get(layout) or LAYOUT_PLATE["handheld"]
+    canvas = Image.new("RGB", PLATE_SIZE, PLATE_COLOR)
+    width, height = image.size
+    scale = min(
+        ratios["height_ratio"] * canvas.height / height,
+        ratios["max_width_ratio"] * canvas.width / width,
+    )
+    resized = image.resize((max(1, int(width * scale)), max(1, int(height * scale))), Image.Resampling.LANCZOS)
+    x = int((canvas.width - resized.width) / 2)
+    y = int(canvas.height - ratios["bottom_ratio"] * canvas.height - resized.height)
+    canvas.paste(resized, (x, y), resized.getchannel("A"))
+    canvas.save(plate_path, "JPEG", quality=90)
+
+
+def trim_alpha(image, threshold=16, pad=12):
+    alpha = image.getchannel("A")
+    mask = alpha.point(lambda value: 255 if value > threshold else 0)
+    box = mask.getbbox()
+    if not box:
+        return image
+    left, top, right, bottom = box
+    left = max(0, left - pad)
+    top = max(0, top - pad)
+    right = min(image.width, right + pad)
+    bottom = min(image.height, bottom + pad)
+    return image.crop((left, top, right, bottom))
+
+
+def fit_long_edge(image, long_edge):
+    width, height = image.size
+    longest = max(width, height)
+    if longest <= long_edge:
+        return image
+    scale = long_edge / float(longest)
+    return image.resize((max(1, int(width * scale)), max(1, int(height * scale))), Image.Resampling.LANCZOS)
+
+
+def upload_file(path):
+    ext = os.path.splitext(path)[1].lower()
+    content_type = "image/png" if ext == ".png" else "image/jpeg"
+    with open(path, "rb") as file:
+        result = requests.post(
+            settings.DOMAIN + "/api/upload",
+            files={"file": (os.path.basename(path), file, content_type)},
+            timeout=120,
+        ).json()
+    url = ((result or {}).get("data") or {}).get("url")
+    if not url:
+        raise RuntimeError("图片上传失败")
+    return url
+
+
+def collect_materials(payload):
+    fields = dict(payload.get("materials") or {})
+    selling = (payload.get("selling_points") or "").strip()
+    excel_path = payload.get("excel_path") or ""
+    if excel_path:
+        parsed = read_excel(excel_path, payload.get("goods_art_no") or "")
+        for key, value in parsed["fields"].items():
+            fields.setdefault(key, value)
+        if not selling:
+            selling = parsed["selling_points"]
+    return {"fields": fields, "selling_points": selling}
+
+
+def read_excel(path, goods_art_no):
+    if not os.path.isfile(path):
+        raise RuntimeError("资料文件不存在")
+    frame = pd.read_excel(path)
+    frame.columns = [str(column).strip() for column in frame.columns]
+    if {"字段", "内容"}.issubset(set(frame.columns)):
+        fields = {}
+        selling = []
+        for _, row in frame.iterrows():
+            name = "" if pd.isna(row["字段"]) else str(row["字段"]).strip()
+            value = "" if pd.isna(row["内容"]) else str(row["内容"]).strip()
+            if not name or not value:
+                continue
+            if name in ("卖点", "卖点文案"):
+                selling.append(value)
+            else:
+                fields[name] = value
+        return {"fields": fields, "selling_points": "\n".join(selling)}
+    row = frame.iloc[0]
+    if "货号" in frame.columns and goods_art_no:
+        matched = frame[frame["货号"].astype(str).str.strip() == str(goods_art_no).strip()]
+        if matched.empty:
+            raise RuntimeError("资料里没有这个货号")
+        row = matched.iloc[0]
+    fields = {}
+    selling = []
+    for column, value in row.items():
+        if pd.isna(value):
+            continue
+        text = str(value).strip()
+        if not text or column == "货号":
+            continue
+        if column in ("卖点", "卖点文案"):
+            selling.append(text)
+        else:
+            fields[column] = text
+    return {"fields": fields, "selling_points": "\n".join(selling)}
+
+
+def public_cutout(item):
+    return {
+        "name": item.get("name") or "",
+        "role": item.get("role") or "",
+        "use_three_view": int(item.get("use_three_view") or 0),
+        "use_detail": int(item.get("use_detail") or 0),
+        "use_for_scene": int(item.get("use_for_scene") or 0),
+        "use_long_scene": int(item.get("use_long_scene") or 0),
+        "crops": item.get("crops") or [],
+    }
+
+
+def backend_post(path, payload):
+    response = requests.post(
+        settings.DOMAIN + "/api/ai_image/camera_machine" + path,
+        headers={
+            "Authorization": "Bearer {}".format(settings.USER_TOKEN or ""),
+            "Content-Type": "application/json",
+        },
+        json=payload,
+        timeout=180,
+    )
+    try:
+        body = response.json()
+    except Exception:
+        raise RuntimeError("后端没有返回结果")
+    if body.get("code") not in (0, "0"):
+        raise RuntimeError(body.get("message") or body.get("msg") or "后端请求失败")
+    return body.get("data") or {}
+
+
+def download(url, path):
+    response = requests.get(url, timeout=180)
+    response.raise_for_status()
+    with open(path, "wb") as file:
+        file.write(response.content)
+
+
+def compose_copy(base, anchor, title, lines):
+    canvas = base.convert("RGBA")
+    scale = canvas.width / 750.0
+    title_lines = split_title(title)
+    body = [str(line).strip() for line in lines if str(line).strip() and not str(line).startswith("卖点")]
+    if not title_lines and not body:
+        return canvas.convert("RGB")
+    title_font = load_font(int(34 * scale), bold=True)
+    body_font = load_font(int(20 * scale), bold=False)
+    draw = ImageDraw.Draw(canvas)
+    max_width = int(canvas.width * 0.72)
+    wrapped = []
+    for line in title_lines:
+        wrapped.extend(wrap_text(line, title_font, max_width, draw))
+    body_wrapped = []
+    for line in body:
+        body_wrapped.extend(wrap_text(line, body_font, max_width, draw))
+    block = measure_block(draw, wrapped, title_font, body_wrapped, body_font, scale)
+    if anchor == "center":
+        origin = (int((canvas.width - block[0]) / 2), int((canvas.height - block[1]) / 2))
+        plate = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+        plate_draw = ImageDraw.Draw(plate)
+        pad = int(28 * scale)
+        plate_draw.rounded_rectangle(
+            [origin[0] - pad, origin[1] - pad, origin[0] + block[0] + pad, origin[1] + block[1] + pad],
+            radius=int(18 * scale),
+            fill=(0, 0, 0, 140),
+        )
+        canvas = Image.alpha_composite(canvas, plate)
+    else:
+        canvas = Image.alpha_composite(canvas, shade_layer(canvas.size, anchor))
+        origin = text_origin(canvas.size, block, anchor, scale)
+    draw = ImageDraw.Draw(canvas)
+    y = origin[1]
+    for line in wrapped:
+        x = align_x(origin[0], block[0], draw.textlength(line, font=title_font), anchor)
+        draw.text((x, y), line, font=title_font, fill=(255, 255, 255, 255))
+        y += int(46 * scale)
+    if wrapped and body_wrapped:
+        y += int(8 * scale)
+    for line in body_wrapped:
+        x = align_x(origin[0], block[0], draw.textlength(line, font=body_font), anchor)
+        draw.text((x, y), line, font=body_font, fill=(255, 255, 255, 230))
+        y += int(32 * scale)
+    return canvas.convert("RGB")
+
+
+def render_three_view(cutouts, image, anchor):
+    canvas = Image.new("RGB", (1500, 2400), (243, 240, 235))
+    plan = ((image.get("extra") or {}).get("plan") or [])
+    margin, gap = 48, 28
+    card_w = 1500 - margin * 2
+    card_h = (2400 - margin * 2 - gap * 2) // 3
+    y = margin
+    for item in plan:
+        card = product_card(find_cutout(cutouts, item.get("role")), card_w, card_h, item.get("label") or "")
+        canvas.paste(card, (margin, y))
+        y += card_h + gap
+    return compose_copy(canvas, anchor, image.get("title") or "", image.get("lines") or [])
+
+
+def render_detail(cutouts, image, anchor):
+    canvas = Image.new("RGB", (1500, 2400), (243, 240, 235))
+    plan = ((image.get("extra") or {}).get("plan") or [])[:4]
+    if not plan:
+        return compose_copy(canvas, anchor, image.get("title") or "", image.get("lines") or [])
+    margin, gap = 48, 24
+    card_w = 1500 - margin * 2
+    card_h = (2400 - margin * 2 - gap * (len(plan) - 1)) // len(plan)
+    y = margin
+    for item in plan:
+        cutout = find_cutout(cutouts, item.get("role"), item.get("name"))
+        cropped = crop_box(open_cutout(cutout), item.get("box") or [0, 0, 1, 1]) if cutout else None
+        card = product_card_image(cropped, card_w, card_h, item.get("label") or "细节")
+        canvas.paste(card, (margin, y))
+        y += card_h + gap
+    return compose_copy(canvas, anchor, image.get("title") or "", image.get("lines") or [])
+
+
+def render_text_page(title, lines, anchor):
+    canvas = Image.new("RGB", (1500, 2400), (243, 240, 235))
+    return compose_copy(canvas, anchor, title, lines)
+
+
+def product_card(cutout, width, height, label):
+    image = open_cutout(cutout) if cutout else None
+    return product_card_image(image, width, height, label)
+
+
+def product_card_image(image, width, height, label):
+    card = Image.new("RGB", (width, height), (255, 255, 255))
+    draw = ImageDraw.Draw(card)
+    font = load_font(28, bold=False)
+    label_h = 64
+    if image:
+        fitted = fit_box(image, width - 48, height - label_h - 48)
+        x = int((width - fitted.width) / 2)
+        y = int((height - label_h - fitted.height) / 2)
+        card.paste(fitted, (x, max(16, y)), fitted.getchannel("A"))
+    text = label or ""
+    draw.text(((width - draw.textlength(text, font=font)) / 2, height - label_h), text, font=font, fill=(40, 40, 40, 255))
+    return card
+
+
+def open_cutout(cutout):
+    if not cutout or not cutout.get("path") or not os.path.isfile(cutout["path"]):
+        return None
+    image = Image.open(cutout["path"]).convert("RGBA")
+    rotation = int(cutout.get("rotation") or 0)
+    if rotation:
+        image = image.rotate(-rotation, expand=True)
+    return trim_alpha(image)
+
+
+def crop_box(image, box):
+    if image is None:
+        return None
+    left, top, right, bottom = box
+    width, height = image.size
+    return image.crop((
+        int(float(left) * width),
+        int(float(top) * height),
+        int(float(right) * width),
+        int(float(bottom) * height),
+    ))
+
+
+def find_cutout(cutouts, role, name=""):
+    for item in cutouts:
+        if role and item.get("role") == role:
+            return item
+    for item in cutouts:
+        if name and item.get("name") == name:
+            return item
+    return None
+
+
+def fit_box(image, width, height):
+    scale = min(width / image.width, height / image.height)
+    return image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale))), Image.Resampling.LANCZOS)
+
+
+def shade_layer(size, anchor):
+    width, height = size
+    mask = Image.new("L", size, 0)
+    draw = ImageDraw.Draw(mask)
+    span = int(height * 0.46)
+    if anchor in ("top-left", "top-right"):
+        for y in range(span):
+            draw.line([(0, y), (width, y)], fill=int(170 * (1 - y / float(span))))
+    elif anchor in ("bottom-left", "bottom-right"):
+        start = height - span
+        for y in range(start, height):
+            draw.line([(0, y), (width, y)], fill=int(170 * ((y - start) / float(span))))
+    layer = Image.new("RGBA", size, (0, 0, 0, 0))
+    layer.putalpha(mask)
+    return layer
+
+
+def split_title(title):
+    text = str(title or "").replace("卖点:", "").replace("卖点:", "").strip()
+    if not text:
+        return []
+    return [part.strip() for part in re.split(r"\s*/\s*", text) if part.strip()]
+
+
+def wrap_text(text, font, max_width, draw):
+    lines = []
+    current = ""
+    for char in str(text):
+        trial = current + char
+        if draw.textlength(trial, font=font) <= max_width:
+            current = trial
+        else:
+            if current:
+                lines.append(current)
+            current = char
+    if current:
+        lines.append(current)
+    return lines
+
+
+def measure_block(draw, title_lines, title_font, body_lines, body_font, scale):
+    width = 0
+    for line in title_lines:
+        width = max(width, draw.textlength(line, font=title_font))
+    for line in body_lines:
+        width = max(width, draw.textlength(line, font=body_font))
+    height = len(title_lines) * int(46 * scale) + len(body_lines) * int(32 * scale)
+    if title_lines and body_lines:
+        height += int(8 * scale)
+    return int(width), int(height)
+
+
+def text_origin(size, block, anchor, scale):
+    pad = int(42 * scale)
+    width, height = size
+    block_w, block_h = block
+    if anchor == "top-left":
+        return pad, pad
+    if anchor == "top-right":
+        return width - pad - block_w, pad
+    if anchor == "bottom-left":
+        return pad, height - pad - block_h
+    if anchor == "bottom-right":
+        return width - pad - block_w, height - pad - block_h
+    return int((width - block_w) / 2), int((height - block_h) / 2)
+
+
+def align_x(origin_x, block_w, line_w, anchor):
+    if anchor in ("top-right", "bottom-right"):
+        return origin_x + block_w - line_w
+    if anchor == "center":
+        return origin_x + (block_w - line_w) / 2
+    return origin_x
+
+
+def load_font(size, bold=False):
+    path = r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc"
+    if not os.path.isfile(path):
+        path = r"C:\Windows\Fonts\msyh.ttc"
+    if not os.path.isfile(path):
+        raise RuntimeError("没有找到微软雅黑")
+    return ImageFont.truetype(path, max(12, int(size)), index=0)
+
+
+def selling_text(job, anchor, notes):
+    rows = ["来源:{}".format(job.get("selling_source") or ""), "文案位置:{}".format(ANCHOR_LABELS.get(anchor, anchor)), "", "条目"]
+    points = job.get("selling_points") or []
+    if not points:
+        rows.append("(无)")
+    for index, point in enumerate(points, start=1):
+        tags = "、".join(point.get("tags") or [])
+        rows.append("{}. {}".format(index, point.get("text") or ""))
+        rows.append("依据:{}".format(point.get("evidence") or ""))
+        if tags:
+            rows.append("标签:{}".format(tags))
+    rows.append("")
+    rows.append("图片")
+    for image in job.get("images") or []:
+        status = {0: "等待", 1: "生成中", 2: "成功", 4: "本地", -1: "失败"}.get(int(image.get("status") or 0), str(image.get("status")))
+        rows.append("{} {} {}".format(image.get("kind") or "", image.get("scene_name") or "", status))
+        if image.get("lines"):
+            rows.append("文案:" + " / ".join(image.get("lines") or []))
+        if image.get("error_msg"):
+            rows.append("说明:" + image.get("error_msg"))
+    if notes:
+        rows.append("")
+        rows.append("进度")
+        rows.extend(notes)
+    return "\n".join(rows) + "\n"
+
+
+def safe_name(name):
+    return re.sub(r'[\\/:*?"<>|]', "_", str(name))[:40]

+ 44 - 1
python/setup.py

@@ -1,4 +1,29 @@
+import sys
+
 from cx_Freeze import setup, Executable
+from cx_Freeze.finder import ModuleFinder
+
+sys.setrecursionlimit(5000)
+
+_orig_scan_code = ModuleFinder._scan_code
+_scanning_codes = set()
+
+
+def _scan_code(self, module, deferred_imports, code=None, top_level=True):
+    target = code if code is not None else getattr(module, "code", None)
+    if target is not None:
+        marker = id(target)
+        if marker in _scanning_codes:
+            return None
+        _scanning_codes.add(marker)
+    try:
+        return _orig_scan_code(self, module, deferred_imports, code, top_level)
+    finally:
+        if target is not None:
+            _scanning_codes.discard(id(target))
+
+
+ModuleFinder._scan_code = _scan_code
 
 # 创建可执行文件的配置
 executableApp = Executable(
@@ -10,8 +35,26 @@ executableApp = Executable(
 options = {
     "build_exe": {
         "packages": ["custom_plugins"],
+        "includes": ["service.scene_set", "pillow_avif", "zmq"],
         "build_exe": "./dist/",
-        "excludes": ["*.txt"],
+        "excludes": [
+            "scipy",
+            "skimage",
+            "sklearn",
+            "numba",
+            "llvmlite",
+            "matplotlib",
+            "torch",
+            "tensorflow",
+            "IPython",
+            "pytest",
+            "notebook",
+            "jupyter",
+            "PyQt5",
+            "PyQt6",
+            "PySide2",
+            "tkinter",
+        ],
         "include_files": [
             "config.ini",
             "action.json",