From ac4ab22ac2448a6aab97404acbd06d18fc089e5f Mon Sep 17 00:00:00 2001 From: R524809 Date: Wed, 26 Aug 2026 17:38:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E5=93=81=E8=AF=95=E7=AE=97?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/v2.1/README.md | 146 +++ docs/v2.1/api.md | 110 +++ docs/v2.1/collect.md | 109 +++ docs/v2.1/image-suite.md | 94 ++ docs/v2.1/trial-page.md | 158 ++++ studio/src/layouts/menuConfig.tsx | 3 + .../src/pages/collection/CollectionPage.tsx | 7 +- studio/src/pages/trial/AiImageGenModal.tsx | 159 ++++ studio/src/pages/trial/TrialExportPanel.tsx | 219 +++++ studio/src/pages/trial/TrialInfoPanel.tsx | 234 +++++ studio/src/pages/trial/TrialPage.tsx | 162 ++++ studio/src/pages/trial/TrialPricingPanel.tsx | 460 ++++++++++ studio/src/pages/trial/TrialSuitePanel.tsx | 866 ++++++++++++++++++ studio/src/pricing/pricing.ts | 75 ++ studio/src/router/index.tsx | 2 + studio/src/services/suite.ts | 195 ++++ studio/src/utils/file.ts | 57 ++ studio/tsconfig.app.tsbuildinfo | 2 +- 18 files changed, 3055 insertions(+), 3 deletions(-) create mode 100644 docs/v2.1/README.md create mode 100644 docs/v2.1/api.md create mode 100644 docs/v2.1/collect.md create mode 100644 docs/v2.1/image-suite.md create mode 100644 docs/v2.1/trial-page.md create mode 100644 studio/src/pages/trial/AiImageGenModal.tsx create mode 100644 studio/src/pages/trial/TrialExportPanel.tsx create mode 100644 studio/src/pages/trial/TrialInfoPanel.tsx create mode 100644 studio/src/pages/trial/TrialPage.tsx create mode 100644 studio/src/pages/trial/TrialPricingPanel.tsx create mode 100644 studio/src/pages/trial/TrialSuitePanel.tsx create mode 100644 studio/src/services/suite.ts create mode 100644 studio/src/utils/file.ts diff --git a/docs/v2.1/README.md b/docs/v2.1/README.md new file mode 100644 index 0000000..63baf0f --- /dev/null +++ b/docs/v2.1/README.md @@ -0,0 +1,146 @@ +# Ozon Seller Kit V2.1 方案总览(转向:放弃 API 直传,聚焦试算工作流) + +> 状态:方案定稿 + 分阶段实施(Phase A 前端先行,进行中) +> 最后更新:2026-08-26 +> 定位:本文是 V2.1 全部设计文档的入口与决策总表。先读本文,再按需读分册。 +> 前置:`docs/v2/`(V2 方案)。V2.1 是对 V2 主链路的一次「降本转向」,不推翻 V2 的基础设施决策(数据库/存储/鉴权/工作台化)。 + +--- + +## 1. 背景与转向 + +V2 原定主链路是「采集 → 编辑 → **Ozon Seller API 直传发布**」。实施到发布环节发现: +ImportProductsV3 直传要求类目字典对齐、属性 id 映射(含 `is_aspect` 变体属性)、description_category_id/type_id 解析、 +多变体合并规则、错误码逐条排查等大量细节字段对齐工作。对个人开发者而言实现成本过高、性价比不高。 + +V2.1 退而求其次,把主链路改为: + +``` +V2(原计划) V2.1(现在) +采集 → 采集箱 → 编辑 → API 直传发布 采集 → 采集箱 → 商品试算页 → CSV 导出 + ↑ ↑ + 大量 Ozon 字段对齐工作 人工在 Ozon 后台上品(粘贴/上传) +``` + +核心变化只有一条:**「上品」这一步从 API 自动化退回人工,把省下来的精力放在试算页的效率工具上** +(价格试算、俄文文案、AI 图片生成、采购地址维护、数据入库、CSV 导出)。 +采集、采集箱、商品数据落库等 V2 已实现的基础全部保留。 + +--- + +## 2. 端到端数据流 + +``` +① 浏览 Ozon / 1688 / 淘宝商详页 + │ 插件页内悬浮面板 → 快速采集(采集方案以 image-suite-studio 为主要参考) + ▼ +② 插件 POST /api/materials 上传(texts + images,已实现) + │ 服务端落库为采集箱商品(stage=collected),异步下载转存图片 + ▼ +③ 双动作(插件内完成): + ├─ a. 同步到采集箱(已在 ② 落库) + └─ b. 自动打开商品试算页 {studio}/trial/{product_id}(新标签页) + ▼ +④ 商品试算页(studio 新页面,操作流水线对齐 web/ 工具台 v1): + ├─ 01 商品信息核对/补录(标题、重量、尺寸、描述、采买地址、货号) + ├─ 02 价格试算(进货价/净利率/贴单费/物流等级/预留折扣/汇率 → 销售价 ¥/₽、利润、成本) + ├─ 03 俄文文案生成(AI 标题/简介/标签,一键回填) + ├─ 04 图片与 AI 生图(采集图勾选 → 套图规划 → 一键生成;单张 AI 图生图;导出) + └─ 05 入库与导出(每步自动落库 product.pricing/copy;CSV / 组合码导出) + ▼ +⑤ 上品:人工在 Ozon 卖家后台录入,用 CSV / 组合码批量回填价格与货号 +``` + +--- + +## 3. V2.1 决策总表(D 系列) + +| 编号 | 决策 | 内容 | 理由 | +|---|---|---|---| +| **D1** | 放弃 Ozon API 直传 | `server/api/publish.py`、`services/publish.py`、店铺/类目代理等发布链路代码**保留但冻结**,不作为主链路;上品方式 = CSV 导出 + 人工后台录入 | ImportProductsV3 字段对齐工作量对个人不可承受;人工上品每品只花几分钟 | +| **D2** | 采集以 image-suite-studio 为主要参考 | 采集引擎(collector/profiles/平台文件)、页内悬浮面板 UI、三平台覆盖(Ozon/1688/淘宝天猫)全部以 `../image-suite-studio/extension` 的实现为准 | 该项目已跑通三平台商详页采集(Ozon 四路径降级 + 阿里系 MAIN-world 桥),且与本仓 extension-v2 同源,合并成本低 | +| **D3** | 采集完成双动作 | 上传 `/api/materials` 入库(已实现)+ 插件自动打开 `{studio}/trial/{product_id}` | 采集即进入试算流水线,减少手工跳转 | +| **D4** | 商品试算页 studio 化 | 新增 `/trial/:id` 页面,功能与操作流水线对齐 `web/ozonSeller.html`(计价/俄文文案/图片水印/采购地址/登记/CSV/组合码),数据从 localStorage 升级为 products 表落库 | web/ v1 已验证好用,冻结只读;studio 是其 React+antd 版本 | +| **D5** | 图片生成对齐 image-suite-studio | 套图规划(DeepSeek)→ 一键生成(多 provider 模型路由:豆包/通义/RightAPI)→ 服务端水印 → 导出 ZIP;生成图回写 `product_assets(generated)` | 套图能力已在 image-suite-studio 验证;服务端代码整体平移复用 | +| **D6** | 单张 AI 图生图 | 采集图与生成图的每个图片单元格都有「AI 生图」按钮:弹窗输入要求 + 选模型 → 单张生成 | 套图批量生成之外补充精修单图的能力 | +| **D7** | 前端先行 | 先开发试算页前端:服务层按本文 [`api.md`](./api.md) 契约编写;未实现的服务端接口以明确报错提示(不阻断页面其它功能),后再开发服务端 | 先把交互/流水线定型,服务端按前端契约补齐,避免返工 | +| **D8** | 存储沿用 V2 基础设施 | 商品/素材落 PostgreSQL(本地开发 SQLite),图片走 `services/storage.py` 抽象(本地 `data/media/` 兜底,七牛可选) | 不为转向重做存储;D1 冻结后七牛不再是硬依赖 | + +--- + +## 4. 现状盘点(2026-08-26) + +### 4.1 已实现、V2.1 直接复用 + +| 能力 | 位置 | 说明 | +|---|---|---| +| Ozon 采集上传落库 | `extension-v2/` + `server/api/collection.py` | `POST /api/materials`、`/api/materials/bytes`、去重、异步转存 | +| 采集箱 | `studio/src/pages/collection/CollectionPage.tsx` | 列表/筛选/删除/复制 | +| 商品 CRUD | `server/api/products.py` + `studio/src/services/product.ts` | 详情/PATCH 自动保存(raw/pricing/copy JSON 均可存) | +| 计价纯函数 | `studio/src/pricing/pricing.ts` | 从 web/app.js 抄录的核心公式(V2.1 需补尺寸校验等,见 trial-page.md) | +| 俄文文案 | `server/api/ai.py` + `studio/src/pages/product/CopyPanel.tsx` | `/api/ai/models`、`/api/ai/copy`,生成+回填交互完整 | +| 智能修图(万相) | `server/api/image.py` + `studio/src/pages/ai-image/` | `/api/image/edit` 保留,独立页面不动 | +| 汇率 | `server/api/fx.py` + `studio/src/services/fx.ts` | `GET /api/fx` | +| 素材上传(字节) | `server/api/collection.py:204` | `POST /api/materials/bytes`,试算页「上传图片」直接用 | +| 登录鉴权 | `server/api/auth.py` + studio RequireAuth | APP_TOKEN 换 JWT | + +### 4.2 V2.1 待开发 + +| 能力 | 端 | 分册 | +|---|---|---| +| 商品试算页(5 区块) | studio 前端(**Phase A,本次**) | [`trial-page.md`](./trial-page.md) | +| 套图规划/一键生成/单张 AI 生图 前端 | studio 前端(**Phase A,本次**) | [`image-suite.md`](./image-suite.md) | +| suite 系列服务端接口(plan/generate/suites/image-edit/export) | server(Phase B) | [`api.md`](./api.md) | +| 采集对齐 image-suite-studio + 自动打开试算页 | extension-v2(Phase C) | [`collect.md`](./collect.md) | +| 批量 CSV 服务端导出(可选优化) | server(Phase D) | [`api.md`](./api.md) §7 | + +### 4.3 冻结(不删代码,不再投入) + +- Ozon 发布链路:`server/api/publish.py`、`server/services/publish.py`、`server/api/categories.py`、`server/services/ozon_client.py` +- studio 商品编辑页的「属性映射」「发布」区块(页面保留,作为长期能力储备) +- `docs/v2/ozon-publish.md`、`docs/v2/multi-sku.md` 的直传方案(远期若重启再参考) +- `web/` v1 工具台(继续冻结只读,直到试算页功能对齐后废弃) + +--- + +## 5. 分册索引 + +| 文档 | 内容 | 什么时候读 | +|---|---|---| +| [`collect.md`](./collect.md) | 采集方案:以 image-suite-studio 为主要参考的引擎架构、三平台路径、采集后双动作、插件改造点 | 做插件(Phase C)时读 | +| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 | +| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 | +| [`api.md`](./api.md) | 新增/复用的服务端 REST 契约(前端服务层已按此开发) | 前后端联调时读 | + +--- + +## 6. 实施阶段 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| **Phase A(本次)** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 本次交付 | +| **Phase B** | 服务端:`/api/suite/*`、`/api/suites/*`、`/api/export/images`、`/api/proxy-image`(从 image-suite-studio/server 平移 generator/planner/prompts/watermark/tasks/storage) | 待开工 | +| **Phase C** | 插件:extension-v2 的 collector/profiles/面板对齐 image-suite-studio(补 1688/淘宝、页内悬浮面板),采集成功后自动打开 `/trial/{id}` | 待开工 | +| **Phase D** | 打磨:批量 CSV 服务端化、生成图自动回写素材、水印增强(拖动定位/白底处理评估移植 web v1 能力)、组合码批量导出优化 | 待开工 | + +每个阶段结束都可独立运行:Phase A 结束时,试算页的计价/文案/素材上传/入库立即可用(对接已有接口),套图相关按钮点击会提示「服务端接口未实现」。 + +--- + +## 7. 与 V2 文档的关系 + +- V2 的基础设施决策(D1 契约云端化、D3 PostgreSQL、D6 工作台化、D7 鉴权)**全部沿用**。 +- V2 的 D 图片方案 B(套图 + 智能修图)**升级落地**:套图引擎不再自研,直接平移 image-suite-studio 的服务端实现。 +- V2 的发布链路(`ozon-publish.md`)**冻结**;`api.md` 中的 `/api/publish`、`/api/categories` 不再是主链路。 +- 若存在分歧,以 `docs/v2.1/` 为准。 + +--- + +## 8. 关键风险 + +| 风险 | 级别 | 对策 | +|---|---|---| +| image-suite-studio 服务端是无状态内存任务表,重启丢任务 | 🟡 低 | 接受(与该项目建设一致):图片落盘 storage 不丢,任务状态丢了重新生成即可;文档明示 | +| 采集图防盗链(Ozon/阿里 CDN)在 studio 页面直接展示可能失败 | 🟡 中 | 素材展示优先 `stored_url`(服务端已转存);Phase B 补 `/api/proxy-image` 兜底 | +| 前端先行的契约与服务端实现不一致导致返工 | 🟡 中 | api.md 即契约真源,服务端按文档实现;字段命名对齐 image-suite-studio 已验证的结构 | +| CSV 人工上品仍是手工活 | 🟢 低 | 组合码 + CSV 已是 web/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 | diff --git a/docs/v2.1/api.md b/docs/v2.1/api.md new file mode 100644 index 0000000..fd47261 --- /dev/null +++ b/docs/v2.1/api.md @@ -0,0 +1,110 @@ +# V2.1 API 契约(新增 + 复用) + +> 状态:前端服务层已按本文开发(Phase A);服务端 Phase B 按 本文实现。 +> 通用:鉴权 `Authorization: Bearer `(studio 走 axios 拦截器自动附带);错误统一 FastAPI `detail`。 + +--- + +## 1. 复用的现有接口(Phase A 即可用) + +| 接口 | 用途(试算页) | +|---|---| +| `GET /api/products/{id}`、`PATCH /api/products/{id}` | 商品详情 / 试算页自动保存(raw/pricing/copy/price/weight/... 全字段) | +| `GET /api/products/{id}/assets` | 素材列表(分组/规格/状态/stored_url) | +| `GET /api/products?stage&q&page&page_size` | 批量 CSV / 组合码导出的数据源 | +| `POST /api/materials/bytes` | 试算页「上传图片」(FormData:`product_id, group_key=upload, file`)→ `{asset_id, status}` | +| `GET /api/fx` | 汇率 `{rate, source, ts}` | +| `GET /api/ai/models`、`POST /api/ai/copy` | 俄文文案(模型白名单 / 生成) | +| `POST /api/image/edit` | 智能修图页保留,不在试算页主链路 | + +## 2. 套图规划 `POST /api/suite/plan`(Phase B) + +**Req** +```jsonc +{ + "product_id": "uuid", // 可选:便于日志与上下文缓存 + "texts": [ { "kind": "title|price|params|selling_point|desc|brand|sales|shop", + "content": "…", "pairs": [ { "key": "…", "value": "…" } ] } ], + "sku_variants": ["粉色", "蓝色"], + "image_stats": { "main": 8, "sku": 6, "detail": 12 }, + "platform": "ozon", + "requirements": "…|null" // 用户强制要求,最高优先级 +} +``` +**Res** `{ "summary": "一句话方案说明", "items": [ { "kind": "white_bg", "title": "白底主图", "detail": "…", "prompt_hint": "…", "count": 2, "variant_name": "粉色|null" } ] }` + +实现要点:DeepSeek 单行紧凑 JSON;kind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总数建议 8-15。同步接口(数秒级)。 + +## 3. 一键生成 `POST /api/suite/generate`(Phase B) + +**Req** +```jsonc +{ + "product_id": "uuid", + "texts": [ …同上… ], + "images": [ { "url": "…", "group_key": "main|sku|detail|upload", "variant_name": "粉色|null" } ], // 勾选参考底图 + "style_set": 1, + "style_prompt": "…|null", // 用户改写的风格提示词,覆盖后端模板 + "requirements": "…|null", + "plan": [ …PlanItem(count>0)… ], + "platform": "ozon", + "model": "gpt-image-2-vip", + "watermark": { "enabled": true, "type": "image|text", "text": "xiongmaoyx", "opacity": 30 } // 关闭时缺省 +} +``` +**Res** `{ "suite_id": "…" }` + +实现要点:方案按 count 展开逐张 job;内存任务表 + 全局串行队列;逐张 build_prompt(模型家族分册)→ 参考图解析(variant 精确匹配→main 第 1 张;≤2 张)→ 生图 → 水印 → 落 storage;生成图追加 `product_assets(group_key='generated')`。 + +## 4. 任务查询 `GET /api/suites/{id}`(Phase B) + +**Res** +```jsonc +{ + "id": "…", "status": "pending|running|done|partial|failed", + "style_set": 1, "platform": "ozon", "lang": "俄文", "ratio": "3:4", "provider": "rightapi", + "total": 8, // 计划总张数(images 逐张追加,过程中 length < total) + "images": [ { "type_id": "white_bg", "name": "白底主图-01.png", "url": "/media/suites/…png", + "status": "ok|failed", "error": null } ], + "error": null +} +``` +前端 3s 轮询;`done/partial/failed` 终态;后端重启后 404,前端提示重新生成。 + +## 5. 结果导出 `GET /api/suites/{id}/zip`(Phase B) + +→ `application/zip` blob(按方案标题命名内部文件)。404/失败返回 `detail` JSON。 + +## 6. 单张 AI 生图 `POST /api/suite/image-edit`(Phase B) + +**Req** +```jsonc +{ + "product_id": "uuid", + "image_url": "…", // stored_url 或 source_url(服务端代下绕防盗链) + "prompt": "把背景换成纯白色摄影棚", // 必填 + "model": "nano-banana-2", + "append": true // true:结果追加 product_assets(generated) +} +``` +**Res** `{ "url": "/media/suites/….png", "asset_id": "uuid|null" }` + +同步接口(单张 5s-5min,前端 axios 超时 120s+;gpt 系列偶发超时由前端重试提示兜底)。 +实现要点:复用 §3 的 provider 层与 prompt 家族;参考图 = 传入单图;Ozon 规格(俄文 3:4)。 + +## 7. 导出与代理(Phase B / D) + +| 接口 | 方法 | Req | Res | +|---|---|---|---| +| `/api/export/images` | POST | `{ "title": "…", "images": [{ "url", "groupName", "variantName", "key" }] }` | zip blob(分组建文件夹) | +| `/api/proxy-image?url=` | GET | 源站图片 URL | 图片字节(带站点 Referer 代下,绕防盗链) | +| `/api/export/trial-csv` | GET | query:`stage?` | csv blob(16 列,见 trial-page.md §7;Phase D 服务端化,替代前端拼装) | + +## 8. 采集相关(Phase C 使用,均已实现) + +- `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。 +- `GET /api/collected?platform=&itemId=`:采集前查重。 + +## 9. 冻结接口(不投入,保留代码) + +`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。 diff --git a/docs/v2.1/collect.md b/docs/v2.1/collect.md new file mode 100644 index 0000000..b53f66d --- /dev/null +++ b/docs/v2.1/collect.md @@ -0,0 +1,109 @@ +# V2.1 采集方案(以 image-suite-studio 为主要参考) + +> 状态:方案定稿(Phase C 实施) +> 参考项目:`/Users/joey-xd/sites/seller-store/image-suite-studio`(下称 ISS) +> 原则:**采集功能以 ISS 的实现为准**。ISS 的采集引擎本就与本仓 extension-v1/v2 同源,V2.1 把 ISS 的最新版采集实现(含 1688/淘宝与页内悬浮面板)合并回本仓,并接上本仓已有的服务端上传链路。 + +--- + +## 1. 采集形态与架构(照搬 ISS) + +Chrome MV3 扩展(WXT + React + antd),四个 content script 注入三个平台的四个站点: + +| 站点 | 匹配 | +|---|---| +| Ozon | `*.ozon.ru` / `*.ozon.kz` / `*.ozon.by` | +| 1688 | `detail.1688.com` | +| 淘宝/天猫 | `item.taobao.com` / `detail.tmall.com` | + +``` +extension/ +├── entrypoints/ +│ ├── background.ts # Service Worker,唯一出网口(上传/代理 fetch) +│ ├── content/index.ts # 采集脚本(isolated world),挂 window.__SuiteCollector +│ ├── bridge.content.ts # MAIN world 桥:postMessage 读页面全局变量(window.context / __ICE_APP_CONTEXT__) +│ └── panel.content.ts # 页内悬浮按钮「套」+ 右侧滑出面板(Shadow DOM + iframe 懒加载) +├── src/ +│ ├── collector/ # 采集引擎(见 §2) +│ ├── profiles/ # 声明式站点配置(ozon/1688/taobao) +│ ├── api/client.ts # 上传 payload 组装 +│ └── storage/settings.ts# baseUrl/token/水印设置 +└── entrypoints/sidepanel/ # 面板 UI(App.tsx,iframe 复用) +``` + +UI 交互形态采用 ISS 的**页内悬浮面板**(在商品页右侧滑出,iframe 加载 sidepanel.html), +比 Chrome 原生 Side Panel 更贴近「边看商品边采集」的操作。 + +## 2. 采集引擎(多源合并,主路径优先、DOM 兜底) + +统一入口 `src/collector/scan.ts`:`scanCurrentPage()` → `matchProfile(location.href)` → 分发平台文件 → `merge.ts` 装配 `ScanResult`。 + +| 平台 | 主路径 | 兜底/补充 | 关键实现(ISS 路径) | +|---|---|---|---| +| **Ozon** | ① SSR `data-state` widget 白名单(webGallery/webPrice/webProductHeading/webShort\|Detailed\|Characteristics/webAspects/webReviewProductScore/breadCrumbs) | ② JSON-LD ③ 站内 `entrypoint-api.bx` JSON(两 URL 合并:默认页 + `?layout_page_index=2` 描述页)④ DOM 选择器 + 模拟人工滚动触发懒加载 | `collector/ozon-state.ts`、`jsonld.ts`、`ozon-api.ts`、`platforms/ozon.ts` | +| **淘宝/天猫** | ① MAIN world 桥读 `__ICE_APP_CONTEXT__`(loaderData…item/skuBase/priceVO/参数表;SKU 缺失从 `__general_skupanel_cache_data` 容错深搜;**不调 mtop 签名接口防风控**) | ② DOM(CSS Modules 前缀匹配 `[class*="mainTitle--"]`;详情图 `excludeWithin` 排除评价/推荐;**desc 故意不采**) | `collector/taobao-state.ts`、`ssr.ts`、`platforms/taobao.ts` | +| **1688** | ① MAIN world 桥读 `window.context`(deepFind BFS:gallery/tempModel/skuModel.skuProps 全维展开 + skuMapOriginal 价格库存 + pieceWeightScaleInfo 自动生成尺寸重量参数/productAttributes) | ② DOM(`#productAttributes` cells 模式参数表、`#detail` 详情图) | `collector/1688-state.ts`、`platforms/1688.ts` | + +通用提取器(照搬):`text.ts`(join/first/table/cells 四模式)、`image.ts`(srcProps 降级链 + srcset 挑最大 + Shadow DOM 穿透 + SKU 按 `url::规格名` 去重)、`url.ts`(原图还原:Ozon `/wc\d+/`、阿里 `xxx.jpg_400x400.jpg → xxx.jpg`;`toThumbUrl` 反向缩略图)、`merge.ts`(primary 先入 fallback 填缺,按 dedupeKey 去重,重排 main→sku→detail→video)。 + +### 采集数据结构(ScanResult,与 ISS 完全一致) + +```jsonc +{ + "platform": "ozon | 1688 | taobao", + "itemId": "…|null", "url": "…", "scannedAt": 1724000000000, + "source": "state | ssr | jsonld | api | dom | mixed", + "breadcrumbs": [ { "name": "…", "href": "…", "searchCategoryId": 7041 } ], // 仅 Ozon + "stats": { "main": 8, "sku": 6, "detail": 12, "video": 1 }, + "warnings": ["详情图为 0 张,请滚动到页面底部后重新采集"], + "texts": [ { "kind": "title|price|params|selling_point|desc|brand|sales|shop", + "content": "…", "pairs": [ { "key": "材质", "value": "PP塑料" } ] } ], + "images": [ { "key": "main-001", "groupKey": "main", "groupName": "主图", + "variantName": "粉色", "url": "原图URL", "thumbUrl": "缩略图URL", + "index": 0, "type": "img|video", "width": 800, "height": 800 } ] +} +``` + +## 3. 采集后双动作(V2.1 新增) + +ISS 采集完是「本地展示 + 生图」,本仓在其后追加落库与跳转: + +``` +快速采集(悬浮面板) + │ scanCurrentPage() → ScanResult + ▼ +① 同步到采集箱:POST /api/materials(本仓已实现) + │ payload = { platform, item_id, url, texts, images: [{url, group_key, variant_name, dedupe_key, width, height, type}] } + │ 响应 { product_id, created } ← 服务端已有返回,插件此前未用,Phase C 起使用 + ▼ +② 自动打开商品试算页:chrome.tabs.create({ url: `${studioBaseUrl}/trial/${product_id}` }) + │ studioBaseUrl 存插件设置(默认 http://localhost:8900) + ▼ + 面板内提示「已入库并打开试算页」,用户可直接在试算页继续流水线 +``` + +要点: +- `POST /api/materials` 的 texts/images 契约已对齐 ScanResult(extension-v2 现有 `buildMaterialsPayload` 可直接复用)。 +- 重复采集同一商品:服务端按 `platform + item_id` 复用 Product(已实现),插件拿到同一 `product_id`,打开试算页即回到原商品。 +- 1688/淘宝素材上传:`_apply_texts` 平台无关,参数表/重量/尺寸解析已兼容(`_apply_weight_dims` 的 kg→g、cm→mm 换算同样适用 1688 的 `pieceWeightScaleInfo`)。 + +## 4. extension-v2 改造清单(Phase C) + +| # | 改动 | 说明 | +|---|---|---| +| 1 | 用 ISS 的 `src/collector`、`src/profiles`、`src/bridge` 整体替换 extension-v2 对应目录 | ISS 版本更新(含 1688/淘宝 + 多项修复),以 ISS 为准 | +| 2 | 新增 `panel.content.ts` 页内悬浮面板入口 | 保留原生 Side Panel 入口,两种方式共存 | +| 3 | 面板 UI(sidepanel/App.tsx)以 ISS 版为基线,去掉「出图方案/生成结果」区块 | 生图移到试算页做(屏幕更大、可对照商品信息);面板保留:采集结果核对/编辑、勾选图片、上传、下载采集图 | +| 4 | 上传成功后读响应 `product_id`,`chrome.tabs.create` 打开 `{studio}/trial/{product_id}` | 设置项 `studioBaseUrl` | +| 5 | background 保持唯一出网口(代理图片 fetch 绕防盗链 + 上传) | 已是现状 | +| 6 | `GET /api/collected?platform=&itemId=` 采集前查重提示 | 已有接口,面板显示「已采集过,将打开原商品」 | + +## 5. 与 ISS 的差异总表 + +| 维度 | ISS | 本仓 V2.1 | +|---|---|---| +| 采集引擎 | 三平台完整版 | **相同**(Phase C 平移) | +| 采集结果去向 | 仅面板展示(无持久化) | 落库 products/product_assets(复用 V2 已实现) | +| 生图入口 | 面板内 03/04 区块 | 移到 studio 试算页(功能对齐 + 单张 AI 生图增强) | +| 生图后端 | 独立 server(3300 端口,无鉴权) | 并入本仓 server(8800,JWT;Phase B 平移) | +| 采集后动作 | 无 | 入库 + 自动打开 `/trial/{id}` | diff --git a/docs/v2.1/image-suite.md b/docs/v2.1/image-suite.md new file mode 100644 index 0000000..b7e5072 --- /dev/null +++ b/docs/v2.1/image-suite.md @@ -0,0 +1,94 @@ +# V2.1 图片生成方案(套图 + 单张 AI 生图) + +> 状态:Phase A 前端开发中;Phase B 服务端平移 +> 参照:image-suite-studio(下称 ISS)`server/services/{planner,generator,prompts,watermark}.py` + 面板 03/04 区块交互 +> 原则:生成能力与 ISS **功能一致**,交互移到 studio 试算页并新增「单张 AI 生图」。 + +--- + +## 1. 能力总览 + +| 能力 | 说明 | 状态 | +|---|---|---| +| 采集图片展示与勾选 | 按 main/sku/detail/generated 分组;勾选作为参考底图;默认全选主图+SKU 图 | Phase A 前端 ✅ | +| 手动上传补充参考图 | `POST /api/materials/bytes`,独立 `upload` 分组 | Phase A 前端 ✅(接口已有) | +| 套图方案(默认) | 7 种基础类型各 1 张,数量 0-5 可调 | Phase A 前端 ✅ | +| AI 智能规划 | DeepSeek 按商品信息/图组统计/SKU 规格生成方案(`POST /api/suite/plan`) | 前端 ✅ / 服务端 Phase B | +| 一键生成 | 方案展开为逐张任务,串行队列多模型生成(`POST /api/suite/generate` + 轮询) | 前端 ✅ / 服务端 Phase B | +| 生成图水印 | 服务端 Pillow 合成(图片徽章/文字 + 不透明度,右下角) | 前端选项 ✅ / 服务端 Phase B | +| 生成结果与导出 | 结果网格、失败格错误、导出 ZIP(`GET /api/suites/{id}/zip`) | 前端 ✅ / 服务端 Phase B | +| 下载采集图片 | 勾选图打包 ZIP(`POST /api/export/images`,防盗链代理下载) | 前端 ✅ / 服务端 Phase B | +| **单张 AI 生图(V2.1 新增)** | 每张采集图/生成图上「AI 生图」按钮 → 弹窗:要求文本域 + 模型选择 → 单张生成 | 前端 ✅ / 服务端 Phase B | +| 生成图回写商品 | 生成完成的图追加为 `product_assets(generated)` | Phase B/D | + +## 2. 目标平台与规格 + +试算页固定目标平台 **Ozon**:俄文图内文案、3:4(1536×2048)。 +(ISS 的 wb/cn 选项不做;`platform` 字段仍随请求下发,服务端按平台决定文案语言与比例,保留扩展性。) + +## 3. 出图方案(Plan) + +- **类型白名单(10 种)**:white_bg 白底主图 / key_features 核心卖点图 / selling_pt 卖点图 / material 材质图 / lifestyle 场景展示图 / multi_scene 多场景拼图 / ecommerce_detail 电商详情图 / size_chart 尺寸标注图 / sku_collection SKU合集 / custom 创意图。 +- **默认方案**:前 7 种各 1 张;行点击切换 0↔1,± 调 0-5,「全部方案」批量开关。 +- **AI 规划**:请求 `{product_id, texts, sku_variants, image_stats, platform, requirements}`;DeepSeek 输出单行 JSON `{summary, items:[{kind,title,detail,prompt_hint,count,variant_name}]}`;服务端清洗(kind 白名单、count 钳 0-3、幻觉 SKU 丢弃绑定、截断修复)。规则:每个带图 SKU 1 张 white_bg 且绑定 variant_name;总数 8-15。 +- **规划并生成**:勾选后规划完成直接进入生成。 +- SKU 规格来源:`product_assets` 中 `group_key='sku'` 的 `variant_name` 去重。 + +## 4. 生成(Generate) + +``` +POST /api/suite/generate +{ product_id, texts, images:[{url, group_key, variant_name}], // 勾选的参考底图 + style_set, style_prompt, requirements, plan, platform:'ozon', model, watermark } + → { suite_id } +``` + +- **texts 组装**(前端已实现,source = product.raw + 包装字段覆盖): + `title(title_zh||title) / price / brand / sales / shop / params(含尺寸、重量覆盖回参数表) / selling_point / desc`。 +- **参考图解析**(服务端,ISS 逻辑):variant_name 精确匹配 SKU 图 → 回退 main 组第 1 张;material 偏好第 2 张;最多 2 张;本地 storage 直读,远程 URL 带站点 Referer 下载,转 data-URI 进请求。 +- **提示词**:按模型家族分册(alibaba 主体参考语义 / gpt edits 保真语义 / google 主体保持),共用 10 种图类型 builder + 商品上下文(参数表提炼卖点)+ 5 套风格 + `requirements` 置顶强制约束 + 图内文案按语言规范(俄文 ≤4 词)。 +- **任务模型**:进程内内存任务表 + `asyncio.Lock` 全局串行队列(防中转限流);逐张生成 → Pillow 水印 → 按 PNG 魔数定扩展名落 `storage`。**重启丢任务、图片不丢**(与 ISS 一致,接受)。 +- **前端轮询**:`GET /api/suites/{id}` 每 3s;`done/partial/failed` 终态;连续 3 次失败停止跟踪;超预算(5min/张+10min)提示。 + +## 5. 模型路由(与 ISS 一致,服务端 MODEL_PROVIDERS 表) + +| Provider | 模型 | 调用 | +|---|---|---| +| doubao(火山方舟) | doubao-seedream-4-5-251128 | `images/generations` 同步,image 传 data-URI | +| tongyi(DashScope) | qwen-image-3.0-pro / wan2.7-image-pro / wan2.6-image / wan2.6-t2i | qwen 同步;wan 异步 task 轮询;t2i 纯文生图 | +| rightapi(中转) | gpt-image-2 / gpt-image-2-vip / nano-banana(-2/-2-lite/-pro) | `POST /v1/images/generations` async + 任务轮询 + 退避重试 | + +前端下拉 10 个模型带中文特点说明,默认 `gpt-image-2-vip`。密钥:`ARK_API_KEY` / `DASHSCOPE_API_KEY` / `RIGHTAPI_API_KEY` / `DEEPSEEK_API_KEY`(.env,Phase B 接入本仓 settings)。 + +## 6. 风格与要求 + +- 5 套风格(北欧极简/清新明亮/高级感深色/暖调生活/纯净棚拍)+ 每套默认提示词;**风格提示词可改写**(按风格 id 记忆,切风格不丢,可恢复默认)。 +- 「生图要求」文本域:最高优先级强制约束,服务端置于 prompt 最前并声明覆盖一切冲突指令。 +- 水印选项(Popover):开关 + 类型(图片徽章/文字)+ 水印文字 + 不透明度(默认关、`xiongmaoyx`、30%);随生成请求下发,关闭时不带字段;位置右下角;重新生成后生效。 + +## 7. 单张 AI 生图(V2.1 新增) + +交互(前端已实现,`AiImageGenModal.tsx`): + +``` +采集图/生成图单元格 → 「AI生图」按钮 + → 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 生成按钮 + → POST /api/suite/image-edit { product_id, image_url, prompt, model, append: true } + → 返回 { url },弹窗内展示结果,可下载/打开原图 + → 服务端把结果追加为 product_assets(group_key='generated', variant_name=null) +``` + +- 与「智能修图」页(`/ai-image`,wanx 注解编辑)并存:本入口是**轻量单张再生成**(选模型 + 一句话要求),不做标注/遮罩。 +- 服务端实现 = 统一模型路由的单张调用(复用 §5 provider 层 + 参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。 + +## 8. 展示与防盗链 + +- 素材展示优先 `stored_url`(服务端已转存,本地 `/media/...` 或七牛),缺失时回退 `source_url`; + 源站防盗链导致破图时走 `GET /api/proxy-image?url=`(Phase B 提供,ISS 同款)。 +- 预览用 antd `Image.PreviewGroup` 画廊( ← → / Esc 与 ISS 行为一致)。 + +## 9. 导出 + +- **导出 ZIP**(生成结果):`GET /api/suites/{id}/zip`,文件名 `cleanFilename(标题).zip`; +- **下载采集图片**:`POST /api/export/images {title, images:[{url, groupName, variantName, key}]}`,按分组建文件夹打包; +- 前端经 blob + `` 落盘(非扩展环境没有 chrome.downloads)。 diff --git a/docs/v2.1/trial-page.md b/docs/v2.1/trial-page.md new file mode 100644 index 0000000..4fd88b3 --- /dev/null +++ b/docs/v2.1/trial-page.md @@ -0,0 +1,158 @@ +# V2.1 商品试算页方案(studio /trial/:id) + +> 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md) +> 参照:`web/ozonSeller.html` + `web/js/app.js`(v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。 +> 前端代码:`studio/src/pages/trial/` + +--- + +## 1. 定位与入口 + +商品试算页是 V2.1 的**主工作流页面**:采集落库后的一切加工都在这里完成—— +核对商品信息 → 价格试算 → 俄文文案 → 图片/AI 生图 → 入库 → 导出 CSV/组合码 → 人工上 Ozon 后台。 + +入口: +1. **插件采集后自动打开**(Phase C):`/trial/{product_id}` 新标签页; +2. **采集箱「试算」按钮**(Phase A 已加):每行操作列; +3. URL 直达 / 从商品编辑页切换。 + +商品编辑页(`/product/:id`)保留,定位降级为「Ozon 结构化字段的长期储备」;试算页不依赖它。 + +## 2. 页面结构 + +``` +┌──────────────────────────────────────────────────────┬────────────┐ +│ 标题(中文/俄文) · stage 标签 · 保存状态 · 来源链接 · 去编辑页 │ 区域导航 │ +├──────────────────────────────────────────────────────┤ (锚点滚动) │ +│ 01 商品信息 标题/采集价/品牌/销量/店铺/重量/尺寸/货号/ │ │ +│ 采买地址/参数表/描述(可编辑,onBlur 落库) │ │ +│ 02 价格试算 进货价/净利率/贴单费/物流等级/预留折扣/汇率 → │ │ +│ 结果卡(物流费→实收价/净利润→完全成本/总抽成→ │ │ +│ 销售价¥/₽/预留价) + 三类告警 │ │ +│ 03 俄文文案 商品资料 → 模型选择 → 生成标题/简介/标签 → 回填 │ │ +│ 04 图片与AI生图 采集图片(分组勾选/上传/AI生图) + 出图方案(规划/ │ │ +│ 风格/要求/模型/一键生成+进度) + 生成结果(导出) │ → image-suite.md │ +│ 05 入库与导出 入库状态 + 导出本商品CSV + 批量导出CSV + 组合码 │ │ +└──────────────────────────────────────────────────────┴────────────┘ +``` + +## 3. 01 商品信息 + +编辑即落库(onBlur/onChange → `PATCH /api/products/{id}`,与编辑页一致的自动保存模式)。 + +| 字段 | 落库位置 | 说明 | +|---|---|---| +| 标题(中文) | `raw.title_zh`(回退展示 `raw.title`) | 采集原标题写入 `raw.title`,人工整理后的中文名写 `title_zh` | +| 俄文标题 | `name` | 文案生成回填,也可手改 | +| 采集价 / 品牌 / 销量 / 店铺 | `raw.price` / `raw.brand` / `raw.sales` / `raw.shop` | 只读展示 | +| 重量 g | `weight`(`weight_unit='g'`) | 采集已自动 kg→g;试算计价直接用 | +| 长×宽×高 cm | `depth/width/height`(`dimension_unit='mm'`,cm×10 落库) | 计价与尺寸校验用 | +| 货号(SKU) | `offer_id` | 对齐 v1「型号-后缀」,v2.1 用单字段;CSV/组合码的主键 | +| 采买地址 | `raw.purchase_url` | 1688/淘宝采集时默认取 `source_url`;可改;导出 CSV 列 | +| 参数表 | `raw.params`(`[{key,value}]`) | 只读折叠展示;供文案/生图上下文 | +| 描述 | `raw.desc` | 可编辑;供文案/生图上下文 | + +## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变) + +输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、 +`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`discountReserve` 预留折扣%(默认50,≤95)、 +`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸取自 01 区块。 + +**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`): + +| 等级 | 条件 | 公式 | 规则文案 | +|---|---|---|---| +| low | ≤500g | `3.12 + 0.026×重量` | low:3.12 + 0.026×重量 | +| low | >500g | `23.92 + 0.01768×重量` | low:23.92 + 0.01768×重量 | +| high2 | ≤5000g | `22.88 + 0.026×重量` | 高2:22.88 + 0.026×重量 | +| high2 | >5000g | `64.48 + 0.024×重量` | 高2:64.48 + 0.024×重量 | +| high | ≤2000g | `16.64 + 0.026×重量` | 普通:16.64 + 0.026×重量 | +| high | >2000g | `37.44 + 0.01768×重量` | 普通:37.44 + 0.01768×重量 | + +最后 `+ 贴单费`(规则文案追加「 + 通递价」)。 + +**计价链**(与 web/app.js:1035-1053 逐行一致): + +``` +实收价 = 进货价 × (1 + 净利率%) +净利润 = 进货价 × 净利率% +净得率 = low→0.845,其余→0.785 +销售价¥ = (实收价 + 物流费) / 净得率 +平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用 +平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费 +完全成本 = 进货价 + 物流费 + 平台总抽成 +预留后¥ = 销售价 / (1 - 预留折扣%);差额 = 预留后 - 销售价 +销售价₽ = 销售价¥ × 汇率;预留₽ = 预留后¥ × 汇率 +``` + +**校验**(v2.1 新增移植到 `pricing.ts`:`validateDimensions` / `validateLogisticsLevelCny` / `validatePriceRange`,注意等级/区间建议用的销售价单位是 **CNY**,与 v1 一致): + +| 校验 | 规则 | 表现 | +|---|---|---| +| 尺寸硬校验 | low≤500g:三边和≤90 且最长边≤60;low>500g:≤150/≤60;high≤2000g:≤150/≤60;high>2000g:≤250/≤150(high2 按 high 档执行,v1 即如此) | 红色错误,结果卡显示 `--`,不落计价结果 | +| 物流等级建议 | 销售价>140¥ 且 low → 建议高等级;<135¥ 且非 low → 建议低等级 | 橙色提示 | +| 价格区间 | 销售价 ∈ [135,140]¥ → 汇率波动风险 | 橙色提示 | + +**落库**:参数或结果任一变化即重算并保存(对齐编辑页 PriceInfoPanel 模式): + +```jsonc +// product.pricing(PriceInfoPanel 字段的超集,向后兼容) +{ + "purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3, + "discountReserve": 50, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 }, + "logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63, + "fullCommission": 15.02, "totalCost": 67.14, + "sellingPriceCny": 77.51, "reservedPriceCny": 155.02, + "sellingPriceRub": 891.4, "reservedPriceRub": 1782.7, + "calculatedAt": "2026-08-26T12:00:00Z" +} +// 同时回填:price=销售价CNY,old_price=预留后CNY,currency_code=CNY,fx_rate=汇率 +``` + +## 5. 03 俄文文案 + +直接复用编辑页 CopyPanel(`studio/src/pages/product/CopyPanel.tsx`)与服务端 `/api/ai/models`、`/api/ai/copy`: +- 输入区预填 `raw.title_zh + raw.params + raw.desc + 卖点`; +- 生成 2 个推荐标题(俄+中对照)、俄文简介、标签芯片; +- 「回填」标题写 `name + raw.title_zh`,简介写 `description`,结果另存 `product.copy`(后续服务端可加,前端先回填字段)。 + +## 6. 04 图片与 AI 生图 + +详见 [`image-suite.md`](./image-suite.md)。前端(Phase A)已实现完整交互: +采集图片分组展示与勾选、上传(`POST /api/materials/bytes`,已可用)、每张图「AI 生图」弹窗、 +套图方案(默认/AI 规划/数量微调)、5 套风格 + 可编辑风格提示词、生图要求、10 个生图模型、 +一键生成 + 进度轮询 + 结果网格 + 导出 ZIP、下载采集图片。 +套图相关服务端接口 Phase B 提供,前端先行按 api.md 契约调用。 + +## 7. 05 入库与导出 + +**入库**:无独立「录入」按钮——01/02/03 区块的每次编辑都自动落库(v1 的 localStorage 登记表 → products 表)。 +本区块显示入库状态(计价时间 `pricing.calculatedAt`、货号、来源)。 + +**CSV(对齐 web/app.js:1376-1424 的 16 列,UTF-8 BOM + RFC 转义)**: + +``` +货号(sku=offer_id)、商品名(title_zh)、进货价、物流费、平台总抽成、实收价、完全成本、 +销售价、净利润、净利率、卢布销价、重量(g)、尺寸(cm L×W×H)、状态(stage 中文)、 +Ozon地址(https://www.ozon.ru/product/)、采买地址(raw.purchase_url) +``` + +- **导出本商品 CSV**:当前商品一行,立即下载; +- **批量导出 CSV**:拉取商品列表(有 `pricing.calculatedAt` 的)逐个取详情拼装(Phase A 前端拼装,Phase D 可换 `GET /api/export/trial-csv`); +- **组合码**:`货号 预留₽价(两位小数)` 每行一条(无预留₽回退销售价₽),弹窗展示 + 一键复制,用于 Ozon 后台批量改价(对齐 v1「上品组合码」)。 + +## 8. 前端文件清单(Phase A) + +| 文件 | 职责 | +|---|---| +| `studio/src/pages/trial/TrialPage.tsx` | 页面骨架:加载商品+素材、自动保存、区块导航 | +| `studio/src/pages/trial/TrialInfoPanel.tsx` | 01 商品信息 | +| `studio/src/pages/trial/TrialPricingPanel.tsx` | 02 价格试算 | +| `studio/src/pages/product/CopyPanel.tsx` | 03 俄文文案(复用,不新建) | +| `studio/src/pages/trial/TrialSuitePanel.tsx` | 04 图片与 AI 生图(素材/方案/生成/结果) | +| `studio/src/pages/trial/AiImageGenModal.tsx` | 单张 AI 生图弹窗 | +| `studio/src/pages/trial/TrialExportPanel.tsx` | 05 入库与导出 | +| `studio/src/services/suite.ts` | 套图/生图/导出 服务层(按 api.md 契约) | +| `studio/src/pricing/pricing.ts` | 追加 validateDimensions 等三个校验 + 规则文案 | +| `studio/src/utils/file.ts` | CSV 转义 / blob 下载 / 文件名清理 | +| `studio/src/router/index.tsx` + `layouts/menuConfig.tsx` + `CollectionPage.tsx` | 路由 `/trial/:id`、页头信息、采集箱「试算」入口 | diff --git a/studio/src/layouts/menuConfig.tsx b/studio/src/layouts/menuConfig.tsx index 498de3c..1f4319a 100644 --- a/studio/src/layouts/menuConfig.tsx +++ b/studio/src/layouts/menuConfig.tsx @@ -50,6 +50,9 @@ export const getPageInfo = (path: string): { title: string; subtitle: string } = const first = routeMenuConfig[0]; return { title: first.title, subtitle: first.subtitle }; } + if (path.startsWith('/trial/')) { + return { title: '商品试算', subtitle: '计价、俄文文案、AI 生图、入库与导出' }; + } if (path.startsWith('/product/')) { return { title: '商品编辑', subtitle: '编辑商品信息、计价、文案与图片' }; } diff --git a/studio/src/pages/collection/CollectionPage.tsx b/studio/src/pages/collection/CollectionPage.tsx index b513e63..4012d45 100644 --- a/studio/src/pages/collection/CollectionPage.tsx +++ b/studio/src/pages/collection/CollectionPage.tsx @@ -81,7 +81,7 @@ export default function CollectionPage() { title: '商品名', dataIndex: 'name', render: (v, r) => ( - navigate(`/product/${r.id}`)}>{v || '(未命名)'} + navigate(`/trial/${r.id}`)}>{v || '(未命名)'} ), }, { title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' }, @@ -112,9 +112,12 @@ export default function CollectionPage() { }, { title: '操作', - width: 210, + width: 260, render: (_, r) => ( + diff --git a/studio/src/pages/trial/AiImageGenModal.tsx b/studio/src/pages/trial/AiImageGenModal.tsx new file mode 100644 index 0000000..7d61d09 --- /dev/null +++ b/studio/src/pages/trial/AiImageGenModal.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd'; +import { apiErrorMessage } from '@/services/api'; +import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, imageEditSingle } from '@/services/suite'; + +const { Text } = Typography; + +interface Props { + open: boolean; + productId: string; + /** 待生成的源图(采集图或生成图) */ + source: { url: string; name: string } | null; + onClose: () => void; + /** 生成成功回调(服务端 append 后刷新素材列表) */ + onGenerated?: (url: string) => void; +} + +/** + * 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。 + * 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。 + */ +export default function AiImageGenModal({ open, productId, source, onClose, onGenerated }: Props) { + const [prompt, setPrompt] = useState(''); + const [model, setModel] = useState(DEFAULT_IMAGE_MODEL); + const [generating, setGenerating] = useState(false); + const [resultUrl, setResultUrl] = useState(''); + + // 每次打开重置(保留模型选择,方便连续精修) + useEffect(() => { + if (open) { + setPrompt(''); + setResultUrl(''); + setGenerating(false); + } + }, [open]); + + const onGenerate = async () => { + if (!source) return; + if (!prompt.trim()) { + message.warning('请先输入生图要求'); + return; + } + setGenerating(true); + setResultUrl(''); + try { + const r = await imageEditSingle({ + product_id: productId, + image_url: source.url, + prompt: prompt.trim(), + model, + append: true, + }); + setResultUrl(r.url); + message.success('生成完成,已追加到「生成图」分组'); + onGenerated?.(r.url); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setGenerating(false); + } + }; + + return ( + <> + {open && ( +
+
e.stopPropagation()} + > +
+ AI 生图(图生图) + + {source?.name} + +
+
+
+ 原图 + 原图 +
+
+ setPrompt(e.target.value)} + placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'} + disabled={generating} + /> + + setTitleZh(e.target.value)} + onBlur={() => patchRaw({ title_zh: titleZh.trim() })} + /> + + + 俄文标题(文案生成可回填) + setNameRu(e.target.value)} + onBlur={() => onSave({ name: nameRu })} + /> + + + + + + 采集价 + {readOnly(raw.price)} + + + 品牌 + {readOnly(raw.brand)} + + + 销量 + {readOnly(raw.sales)} + + + 店铺 + {readOnly(raw.shop)} + + + + + + 重量 g + setWeightG(v)} + onBlur={() => onSave({ weight: weightG ?? null, weight_unit: 'g' })} + /> + + + 长 cm + setDims((d) => ({ ...d, l: v }))} + onBlur={() => + onSave({ + depth: dims.l == null ? null : dims.l * 10, + dimension_unit: 'mm', + }) + } + /> + + + 宽 cm + setDims((d) => ({ ...d, w: v }))} + onBlur={() => + onSave({ + width: dims.w == null ? null : dims.w * 10, + dimension_unit: 'mm', + }) + } + /> + + + 高 cm + setDims((d) => ({ ...d, h: v }))} + onBlur={() => + onSave({ + height: dims.h == null ? null : dims.h * 10, + dimension_unit: 'mm', + }) + } + /> + + + 货号(SKU) + setOfferId(e.target.value)} + onBlur={() => onSave({ offer_id: offerId.trim() })} + /> + + + 来源 + {product.source_url ? ( + + {product.source_platform || '来源'} 页面 + + ) : ( + + )} + + + +
+ 采买地址(1688 / 拼多多等采购链接) + setPurchaseUrl(e.target.value)} + onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })} + addonAfter={ + + {openUrl && ( + + + + )} + { + copyText(purchaseUrl).then((ok) => ok && message.success('已复制')); + }} + > + + + + } + /> +
+ + {params.length > 0 && ( +
+ 规格 / 参数({params.length} 项), + children: ( +
+ + + {params.slice(0, 60).map((p, i) => ( + + + + + ))} + +
+ {p.key} + {p.value}
+
+ ), + }, + ]} + /> +
+ )} + +
+ 商品描述(供文案生成与生图上下文) + setDesc(e.target.value)} + onBlur={() => patchRaw({ desc })} + /> +
+
+ ); +} diff --git a/studio/src/pages/trial/TrialPage.tsx b/studio/src/pages/trial/TrialPage.tsx new file mode 100644 index 0000000..2d7a619 --- /dev/null +++ b/studio/src/pages/trial/TrialPage.tsx @@ -0,0 +1,162 @@ +/** + * 商品试算页(docs/v2.1/trial-page.md):采集后的主工作流。 + * 01 商品信息 → 02 价格试算 → 03 俄文文案 → 04 图片与AI生图 → 05 入库与导出。 + * 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。 + */ +import { useCallback, useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router'; +import { Card, Menu, Space, Spin, Typography, message } from 'antd'; +import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; +import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage'; +import TrialInfoPanel from './TrialInfoPanel'; +import TrialPricingPanel from './TrialPricingPanel'; +import TrialSuitePanel from './TrialSuitePanel'; +import TrialExportPanel from './TrialExportPanel'; +import CopyPanel from '../product/CopyPanel'; + +const { Title, Text } = Typography; + +const SECTIONS = [ + { id: 'section-info', label: '商品信息' }, + { id: 'section-pricing', label: '价格试算' }, + { id: 'section-copy', label: '俄文文案' }, + { id: 'section-images', label: '图片与AI生图' }, + { id: 'section-export', label: '入库与导出' }, +]; + +export default function TrialPage() { + const { id } = useParams(); + const [product, setProduct] = useState(null); + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [active, setActive] = useState(SECTIONS[0].id); + + const load = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const [p, a] = await Promise.all([getProduct(id), listAssets(id)]); + setProduct(p); + setAssets(a); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + // 滚动监听:高亮当前区域 + useEffect(() => { + const onScroll = () => { + let current = SECTIONS[0].id; + for (const s of SECTIONS) { + const el = document.getElementById(s.id); + if (el && el.getBoundingClientRect().top <= 90) current = s.id; + } + setActive(current); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const save = useCallback( + async (partial: Partial) => { + if (!id || !product) return; + setSaving(true); + try { + const updated = await updateProduct(id, partial); + setProduct(updated); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setSaving(false); + } + }, + [id, product], + ); + + if (loading || !product) { + return ( +
+ +
+ ); + } + + const raw = (product.raw ?? {}) as Record; + const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim(); + + return ( +
+
+
+
+ + + {titleZh || product.name || '(未命名商品)'} + + + {STAGE_LABEL[product.stage] || product.stage} + + + {saving ? '保存中…' : '已自动保存'} +
+ + {product.name && product.name !== titleZh && ( + + 俄文:{product.name} + + )} + 进入商品编辑页 → + +
+ + {/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */} +
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + {/* 右侧区域导航 */} +
+
+ ({ key: s.id, label: s.label }))} + onClick={({ key }) => { + document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }} + /> +
+
+
+ ); +} diff --git a/studio/src/pages/trial/TrialPricingPanel.tsx b/studio/src/pages/trial/TrialPricingPanel.tsx new file mode 100644 index 0000000..fa830fd --- /dev/null +++ b/studio/src/pages/trial/TrialPricingPanel.tsx @@ -0,0 +1,460 @@ +import { useEffect, useState, type CSSProperties } from 'react'; +import { Alert, Button, Card, Col, InputNumber, Radio, Row, Space, Typography, message } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import { ProductDetail } from '@/services/product'; +import { getFxRate } from '@/services/fx'; +import { apiErrorMessage } from '@/services/api'; +import { + calculatePricing, + logisticsFeeRule, + validateDimensions, + validateLogisticsLevelCny, + validatePriceRange, + type LogisticsLevel, + type PricingResult, +} from '@/pricing/pricing'; +import FieldLabel, { fieldRowStyle } from '../product/FieldLabel'; + +const { Text } = Typography; + +/** v1 web/ozonSeller.html 颜色:完全成本/平台总抽成 text-red-400,销售价 text-secondary */ +const COLOR_RED = '#f87171'; +const COLOR_GREEN = '#10b981'; + +interface Props { + product: ProductDetail; + onSave: (p: Partial) => void; +} + +type PricingPayload = Record; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; reserve: number; fxRate: number }>; + +function weightGrams(product: ProductDetail): number { + const w = product.weight ?? 0; + return product.weight_unit === 'kg' ? w * 1000 : w; +} + +function dimsCm(product: ProductDetail): { l: number; w: number; h: number } { + const scale = product.dimension_unit === 'cm' ? 1 : 0.1; + return { + l: (product.depth ?? 0) * scale, + w: (product.width ?? 0) * scale, + h: (product.height ?? 0) * scale, + }; +} + +/** 结果指标:可指定价格颜色(v1 完全成本/总抽成红色、销售价绿色) */ +function Metric({ label, value, rule, color }: { label: string; value: string; rule?: string; color?: string }) { + return ( +
+
{label}
+
{value}
+ {rule &&
{rule}
} +
+ ); +} + +/** 销售价格四宫格:人民币/卢布 × 销售价/划线价,价格居中,划线价带删除线 */ +function SalePriceGrid({ + cnySelling, + cnyLine, + rubSelling, + rubLine, + reserve, + fxRate, + cnyGap, + rubGap, +}: { + cnySelling: string; + cnyLine: string; + rubSelling: string; + rubLine: string; + reserve: number; + fxRate: number; + cnyGap: number | null; + rubGap: number | null; +}) { + const cellStyle: CSSProperties = { + textAlign: 'center', + background: '#fff', + borderRadius: 8, + padding: '12px 8px', + }; + const priceStyle: CSSProperties = { + fontSize: 20, + fontWeight: 700, + color: COLOR_GREEN, + lineHeight: '30px', + }; + const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' }; + return ( +
+
+
+
人民币-销售价格
+
{cnySelling}
+
销售价(现价)
+
+
+
卢布-销售价格
+
{rubSelling}
+
+ 按 1 ¥ = {fxRate ? fxRate.toFixed(2) : '--'} ₽ +
+
+
+
人民币-划线价
+
{cnyLine}
+
+ 预留 {reserve}% 后{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''} +
+
+
+
卢布-划线价
+
{rubLine}
+
+ 预留 {reserve}% 后{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''} +
+
+
+
+ ); +} + +/** + * 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。 + * 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。 + * 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。 + * 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate); + * 尺寸不合规时不落计价结果;预留折扣空间 ≥100% 时划线价无法计算(显示 --,只落销售价)。 + */ +export default function TrialPricingPanel({ product, onSave }: Props) { + const pricing = (product.pricing ?? {}) as PricingPayload; + const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30); + const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100); + const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3); + const [level, setLevel] = useState((pricing.logisticsLevel as LogisticsLevel) ?? 'low'); + const [reserve, setReserve] = useState(pricing.discountReserve ?? 50); + const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0); + const [fxSource, setFxSource] = useState(''); + + // 币种固定人民币(历史数据默认 RUB 时纠正一次) + useEffect(() => { + if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 汇率:无快照时拉取并立即计价入库(试算页首次打开即完成一次入库) + useEffect(() => { + if (!fxRate) { + getFxRate() + .then((r) => { + setFxRate(r.rate); + setFxSource(r.source); + recalc({ fxRate: r.rate }); + }) + .catch((e) => message.error(apiErrorMessage(e))); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const refreshFx = async () => { + try { + const r = await getFxRate(); + setFxRate(r.rate); + setFxSource(r.source); + recalc({ fxRate: r.rate }); + } catch (e) { + message.error(apiErrorMessage(e)); + } + }; + + const compute = (patch: PricingState = {}): PricingResult => + calculatePricing({ + purchasePrice: patch.purchasePrice ?? purchasePrice, + profitRate: patch.profitRate ?? profitRate, + logisticsLevel: patch.level ?? level, + weightG: weightGrams(product), + dims: dimsCm(product), + tdPrice: patch.tdPrice ?? tdPrice, + discountReserve: patch.reserve ?? reserve, + fxRate: patch.fxRate ?? fxRate, + }); + + /** 重算并落库(patch 为本次变化的参数,避免闭包旧值) */ + const recalc = (patch: PricingState = {}) => { + const p = { + purchasePrice: patch.purchasePrice ?? purchasePrice, + profitRate: patch.profitRate ?? profitRate, + tdPrice: patch.tdPrice ?? tdPrice, + level: patch.level ?? level, + reserve: patch.reserve ?? reserve, + fxRate: patch.fxRate ?? fxRate, + }; + const dims = dimsCm(product); + const dimError = validateDimensions(weightGrams(product), dims, p.level); + const r = dimError ? null : calculatePricing({ + purchasePrice: p.purchasePrice, + profitRate: p.profitRate, + logisticsLevel: p.level, + weightG: weightGrams(product), + dims, + tdPrice: p.tdPrice, + discountReserve: p.reserve, + fxRate: p.fxRate, + }); + // 预留折扣空间 ≥100% 时划线价无数学意义(除数 ≤0):只保存销售价,并清空划线价字段 + const lineValid = p.reserve < 100; + const lineFields = r && lineValid + ? { + logisticsFee: r.logisticsFee, + receivedPrice: r.receivedPrice, + profitPrice: r.profitPrice, + commission: r.commission, + fullCommission: r.fullCommission, + totalCost: r.totalCost, + sellingPriceCny: r.sellingPriceCny, + reservedPriceCny: r.reservedPriceCny, + sellingPriceRub: r.sellingPriceRub, + reservedPriceRub: r.reservedPriceRub, + calculatedAt: new Date().toISOString(), + } + : r + ? { + logisticsFee: r.logisticsFee, + receivedPrice: r.receivedPrice, + profitPrice: r.profitPrice, + commission: r.commission, + fullCommission: r.fullCommission, + totalCost: r.totalCost, + sellingPriceCny: r.sellingPriceCny, + sellingPriceRub: r.sellingPriceRub, + reservedPriceCny: null, + reservedPriceRub: null, + calculatedAt: new Date().toISOString(), + } + : {}; + onSave({ + pricing: { + ...pricing, + purchasePrice: p.purchasePrice, + profitRate: p.profitRate, + tdPrice: p.tdPrice, + logisticsLevel: p.level, + discountReserve: p.reserve, + fxRate: p.fxRate, + weightG: weightGrams(product), + dims, + ...lineFields, + }, + fx_rate: p.fxRate, + ...(r && lineValid + ? { price: r.sellingPriceCny, old_price: r.reservedPriceCny } + : r + ? { price: r.sellingPriceCny, old_price: null } + : {}), + currency_code: 'CNY', + }); + }; + + const dimError = fxRate ? validateDimensions(weightGrams(product), dimsCm(product), level) : ''; + const preview = fxRate && !dimError ? compute() : null; + const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : ''; + const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : ''; + const lineValid = reserve < 100; + const discountGap = preview && lineValid ? preview.reservedPriceCny - preview.sellingPriceCny : 0; + + const num = (v: number | undefined | null, digits = 2) => (v == null ? '--' : v.toFixed(digits)); + + return ( + + {/* ── 左:操作项(进货价 → 物流等级) ── */} + + + + 进货价 ¥ + { + const n = v ?? 0; + setPurchasePrice(n); + recalc({ purchasePrice: n }); + }} + /> + + + 净利率 % + { + const n = v ?? 0; + setProfitRate(n); + recalc({ profitRate: n }); + }} + /> + + + + + 贴单费用 ¥ + { + const n = v ?? 0; + setTdPrice(n); + recalc({ tdPrice: n }); + }} + /> + + + 预留折扣空间 % + { + const n = v ?? 0; + setReserve(n); + recalc({ reserve: n }); + }} + /> + + + + + + 汇率 ¥→₽{' '} + {fxSource && ( + + ({fxSource}) + + )} + + + { + const n = v ?? 0; + setFxRate(n); + recalc({ fxRate: n }); + }} + /> + + + ); + + // ── 下载采集图片(勾选图打包 ZIP) ───────────────────────────── + const handleExportImages = async () => { + const selected = imgs.filter((a) => selectedKeys.has(a.id)); + if (selected.length === 0) { + message.warning('请先勾选要下载的图片'); + return; + } + setExportingImages(true); + try { + const blob = await exportImages({ + title: productTitle, + images: selected.map((a) => ({ + url: assetUrl(a), + groupName: GROUP_LABELS[a.group_key] ?? a.group_key, + variantName: a.variant_name, + key: a.id, + })), + }); + downloadBlob(blob, `${cleanFilename(productTitle) || '采集图片'}.zip`); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setExportingImages(false); + } + }; + + // ── AI 智能规划 ───────────────────────────────────────────── + const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? ''; + const totalPlanned = plan.reduce((s, i) => s + i.count, 0); + + const pollSuite = (suiteId: string) => { + stopPolling(); + const startedAt = Date.now(); + let failCount = 0; + let timeoutWarned = false; + pollRef.current = setInterval(async () => { + try { + const s = await getSuite(suiteId); + setSuite(s); + failCount = 0; + // 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断 + const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000; + if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) { + timeoutWarned = true; + message.warning('任务耗时异常,可能已中断(如服务端重启),可稍后观察或重新生成'); + } + if (['done', 'partial', 'failed'].includes(s.status)) { + stopPolling(); + setGenerating(false); + if (s.status === 'partial') message.warning(s.error || '部分生成失败,可重试或更换风格重新生成'); + if (s.status === 'failed') message.error(s.error || '生成失败'); + // Phase B 起服务端会把生成图回写素材(generated 组) + if (s.status !== 'failed') onRefreshAssets(); + } + } catch { + failCount += 1; + if (failCount >= 3) { + stopPolling(); + setGenerating(false); + message.error('连续查询生成进度失败,已停止跟踪;若服务端刚重启,请重新生成'); + } + } + }, 3000); + }; + + const startGenerate = async (planOverride?: PlanItem[]): Promise => { + if (selectedKeys.size === 0) { + message.warning('请先在采集图片区勾选参考底图'); + return; + } + const activePlan = (planOverride ?? plan).filter((p) => p.count > 0); + if (activePlan.length === 0) { + message.warning('出图方案的张数都是 0'); + return; + } + setGenerating(true); + setSuite(null); + try { + const { suite_id } = await generateSuite({ + product_id: product.id, + texts: buildSuiteTexts(product), + images: imgs + .filter((a) => selectedKeys.has(a.id)) + .map((a) => ({ url: assetUrl(a), group_key: a.group_key, variant_name: a.variant_name })), + style_set: styleSet, + style_prompt: currentStylePrompt || null, + requirements: requirements.trim() || null, + plan: activePlan, + platform: 'ozon', + model, + // 水印开启才下发,关闭时不带(服务端按无水印处理) + watermark: watermark.enabled ? watermark : undefined, + }); + pollSuite(suite_id); + } catch (e) { + setGenerating(false); + message.error(apiErrorMessage(e)); + } + }; + + const handlePlan = async () => { + const seq = ++planSeqRef.current; + setPlanning(true); + try { + const skuVariants = Array.from( + new Set(assets.filter((a) => a.group_key === 'sku' && a.variant_name).map((a) => a.variant_name!)), + ); + const stats: Record = {}; + imgs.forEach((a) => { + stats[a.group_key] = (stats[a.group_key] ?? 0) + 1; + }); + const data = await planSuite({ + product_id: product.id, + texts: buildSuiteTexts(product), + sku_variants: skuVariants, + image_stats: stats, + platform: 'ozon', + requirements: requirements.trim() || null, + }); + if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应 + setPlan(data.items); + setPlanSource('ai'); + setPlanSummary(data.summary); + if (planThenGenerate) { + await startGenerate(data.items); + } else { + const total = data.items.reduce((s, i) => s + i.count, 0); + message.info(`AI 方案已生成:${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`); + } + } catch (e) { + if (seq !== planSeqRef.current) return; + message.error(apiErrorMessage(e)); + } finally { + if (seq === planSeqRef.current) setPlanning(false); + } + }; + + const handleGenerate = () => { + if (selectedKeys.size === 0) { + message.warning('请先在采集图片区勾选参考底图'); + return; + } + if (totalPlanned === 0) { + message.warning('出图方案的张数都是 0'); + return; + } + Modal.confirm({ + title: '生成电商套图', + content: ( +
+
+ 目标平台「Ozon」(俄文文案 · 3:4 图片),模型「{model}」,风格「 + {STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考底图{' '} + {selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。 +
+
+ ), + okText: '开始生成', + cancelText: '取消', + onOk: () => startGenerate(), + }); + }; + + const handleExportZip = async () => { + if (!suite) return; + setExportingZip(true); + try { + const blob = await downloadSuiteZip(suite.id); + downloadBlob(blob, `${cleanFilename(productTitle) || '套图'}.zip`); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setExportingZip(false); + } + }; + + const setPlanCount = (idx: number, count: number) => { + setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p))); + }; + const togglePlanRow = (idx: number) => { + setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p))); + }; + const planAllEnabled = plan.every((p) => p.count >= 1); + const togglePlanAll = (on: boolean) => { + setPlan((prev) => prev.map((p) => (on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 }))); + }; + + const doneCount = suite?.images.filter((i) => i.status === 'ok').length ?? 0; + const suiteTotal = suite?.total ?? totalPlanned; + + const patchWatermark = (patch: Partial) => { + setWatermark((prev) => { + const next = { ...prev, ...patch }; + try { + localStorage.setItem(WATERMARK_STORAGE_KEY, JSON.stringify(next)); + } catch { /* 忽略存储失败 */ } + return next; + }); + }; + + const watermarkPopup = ( +
+ patchWatermark({ enabled: e.target.checked })} + > + 生成图加水印 + + {watermark.enabled && ( + <> +
+ patchWatermark({ type: e.target.value })} + options={[ + { value: 'image', label: '图片徽章' }, + { value: 'text', label: '文字' }, + ]} + /> +
+ {watermark.type === 'text' && ( + patchWatermark({ text: e.target.value })} + /> + )} +
+ + 不透明度(%) + + patchWatermark({ opacity: v ?? 30 })} + /> +
+ + 位置固定右下角;重新生成后生效,已生成的图不变 + + + )} +
+ ); + + const selectableCount = imgs.length; + const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0); + + return ( +
+ {/* ── 采集图片 ── */} + + {uploadButton} + + + } + > + {displayGroups.length === 0 ? ( + + ) : ( + + {displayGroups.map((g) => ( +
+
+ + {GROUP_LABELS[g] ?? g} + + {groupImages(g).length} + + + { + const all = groupImages(g).every((a) => selectedKeys.has(a.id)); + toggleGroup(g, !all); + }} + > + {groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'} + + {g === 'main' && ( + + 勾选的图片作为生图参考底图 + + )} +
+
+ {groupImages(g).map((a) => { + const on = selectedKeys.has(a.id); + return ( +
+
+ +
+ toggleKey(a.id)} + style={{ + position: 'absolute', + top: 6, + left: 6, + width: 20, + height: 20, + borderRadius: '50%', + background: on ? '#1677ff' : 'rgba(255,255,255,0.9)', + color: on ? '#fff' : '#bbb', + border: '1px solid #d9d9d9', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + fontSize: 12, + zIndex: 1, + userSelect: 'none', + }} + > + ✓ + + {a.variant_name && ( + + {a.variant_name} + + )} + {a.status !== 'uploaded' && ( + + {a.status === 'failed' ? '转存失败' : '转存中'} + + )} + +
+ ); + })} +
+
+ ))} +
+ )} +
+ + {/* ── 出图方案 ── */} + + {planSource === 'ai' && AI 方案} + setPlanThenGenerate(e.target.checked)} + title="勾选后,AI 规划完成将自动开始生成" + > + 规划并生成 + + + + } + > +
+ {plan.map((p, idx) => ( +
togglePlanRow(idx)} + title="点击勾选/取消该方案(数量用右侧加减调整)" + style={{ + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '6px 8px', + borderRadius: 6, + cursor: 'pointer', + opacity: p.count === 0 ? 0.45 : 1, + background: p.count === 0 ? 'transparent' : 'rgba(22,119,255,0.03)', + }} + > +
+ + 0}>{p.title} + {p.variant_name && {p.variant_name}} + {p.detail && ( + + {p.detail} + + )} + {p.prompt_hint && ( + 🎯 {p.prompt_hint} + )} + +
+ e.stopPropagation()}> + setPlanCount(idx, v ?? 0)} + /> + +
+ ))} +
+
+ togglePlanAll(e.target.checked)} + title="勾选:全部方案至少 1 张(>1 张保留原数量);取消:全部改为 0,方便单独勾选一种方案" + > + 全部方案 + + {planSource === 'ai' && ( + { + setPlan(DEFAULT_PLAN.map((p) => ({ ...p }))); + setPlanSource('default'); + setPlanSummary(''); + }} + > + 恢复默认方案 + + )} + + {planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'} + +
+ +
+
+ 视觉风格 + setStyleSet(v as number)} + options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} + /> + + + +
+
+ + 风格提示词(可编辑,直接决定生成画面风格) + {stylePrompts[styleSet] !== undefined && ( + + setStylePrompts((p) => { + const n = { ...p }; + delete n[styleSet]; + return n; + }) + } + > + 恢复默认 + + )} + + setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))} + /> +
+
+ + 生图要求(优先级最高,强制约束,会覆盖其他设定) + + setRequirements(e.target.value)} + placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印" + /> +
+ + {generating && ( +
+ `${doneCount}/${suiteTotal}`} + /> +
+ )} +