feat: 商品试算页

This commit is contained in:
R524809
2026-08-26 17:38:44 +08:00
parent 6027d8f8d7
commit ac4ab22ac2
18 changed files with 3055 additions and 3 deletions
+146
View File
@@ -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/copyCSV / 组合码导出)
⑤ 上品:人工在 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 | serverPhase B | [`api.md`](./api.md) |
| 采集对齐 image-suite-studio + 自动打开试算页 | extension-v2Phase C | [`collect.md`](./collect.md) |
| 批量 CSV 服务端导出(可选优化) | serverPhase 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/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
+110
View File
@@ -0,0 +1,110 @@
# V2.1 API 契约(新增 + 复用)
> 状态:前端服务层已按本文开发(Phase A);服务端 Phase B 按 本文实现。
> 通用:鉴权 `Authorization: Bearer <JWT>`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 单行紧凑 JSONkind 白名单清洗;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": [ PlanItemcount>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 blob16 列,见 trial-page.md §7Phase 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)。
+109
View File
@@ -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/ # 面板 UIApp.tsxiframe 复用)
```
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 签名接口防风控** | ② DOMCSS Modules 前缀匹配 `[class*="mainTitle--"]`;详情图 `excludeWithin` 排除评价/推荐;**desc 故意不采**) | `collector/taobao-state.ts``ssr.ts``platforms/taobao.ts` |
| **1688** | ① MAIN world 桥读 `window.context`deepFind BFSgallery/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 契约已对齐 ScanResultextension-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 | 面板 UIsidepanel/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 端口,无鉴权) | 并入本仓 server8800JWTPhase B 平移) |
| 采集后动作 | 无 | 入库 + 自动打开 `/trial/{id}` |
+94
View File
@@ -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 |
| tongyiDashScope | 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`.envPhase 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 + `<a download>` 落盘(非扩展环境没有 chrome.downloads)。
+158
View File
@@ -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×重量` | low3.12 + 0.026×重量 |
| low | >500g | `23.92 + 0.01768×重量` | low23.92 + 0.01768×重量 |
| high2 | ≤5000g | `22.88 + 0.026×重量` | 高222.88 + 0.026×重量 |
| high2 | >5000g | `64.48 + 0.024×重量` | 高264.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 且最长边≤60low>500g:≤150/≤60high≤2000g:≤150/≤60high>2000g:≤250/≤150high2 按 high 档执行,v1 即如此) | 红色错误,结果卡显示 `--`,不落计价结果 |
| 物流等级建议 | 销售价>140¥ 且 low → 建议高等级;<135¥ 且非 low → 建议低等级 | 橙色提示 |
| 价格区间 | 销售价 ∈ [135,140]¥ → 汇率波动风险 | 橙色提示 |
**落库**:参数或结果任一变化即重算并保存(对齐编辑页 PriceInfoPanel 模式):
```jsonc
// product.pricingPriceInfoPanel 字段的超集,向后兼容)
{
"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=销售价CNYold_price=预留后CNYcurrency_code=CNYfx_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/<sku>)、采买地址(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`、页头信息、采集箱「试算」入口 |
+3
View File
@@ -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: '编辑商品信息、计价、文案与图片' };
}
@@ -81,7 +81,7 @@ export default function CollectionPage() {
title: '商品名',
dataIndex: 'name',
render: (v, r) => (
<a onClick={() => navigate(`/product/${r.id}`)}>{v || '(未命名)'}</a>
<a onClick={() => navigate(`/trial/${r.id}`)}>{v || '(未命名)'}</a>
),
},
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
@@ -112,9 +112,12 @@ export default function CollectionPage() {
},
{
title: '操作',
width: 210,
width: 260,
render: (_, r) => (
<Space>
<Button size="small" type="primary" ghost onClick={() => navigate(`/trial/${r.id}`)}>
</Button>
<Button size="small" onClick={() => navigate(`/product/${r.id}`)}>
</Button>
+159
View File
@@ -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-editdocs/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<string>(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 && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
background: '#fff',
borderRadius: 10,
padding: 20,
width: 760,
maxWidth: '92vw',
maxHeight: '88vh',
overflow: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 15 }}>AI </Text>
<Text type="secondary" style={{ fontSize: 12, maxWidth: 360 }} ellipsis={{ tooltip: source?.name }}>
{source?.name}
</Text>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Image
src={source?.url}
alt="原图"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Input.TextArea
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'}
disabled={generating}
/>
<Space style={{ marginTop: 12 }} wrap>
<Select
style={{ width: 260 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<Button type="primary" loading={generating} onClick={onGenerate}>
</Button>
<Button onClick={onClose}></Button>
</Space>
<Alert
style={{ marginTop: 16 }}
type="info"
showIcon
message="生成结果会自动追加到「生成图」分组;GPT 系列单张 1-5 分钟,请耐心等待"
/>
</div>
</div>
{resultUrl && (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong></Text>
<a href={resultUrl} target="_blank" rel="noreferrer">
</a>
</Space>
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
</div>
)}
</div>
</div>
)}
</>
);
}
+219
View File
@@ -0,0 +1,219 @@
/**
* 05 入库与导出(docs/v2.1/trial-page.md §7):
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 预留₽价)。
* Phase A 由前端拼装(逐个拉详情),Phase D 可换 GET /api/export/trial-csv。
*/
import { useState } from 'react';
import { Button, Descriptions, Input, message, Modal, Space, Tag, Typography } from 'antd';
import { CopyOutlined, DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
import { getProduct, listProducts, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { copyText, downloadCsv } from '@/utils/file';
import { STAGE_LABEL } from '../collection/CollectionPage';
const { Text } = Typography;
const CSV_HEADER = [
'货号', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
'净利润', '净利率', '卢布销价', '重量(g)', '尺寸(cm)', '状态', 'Ozon地址', '采买地址',
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildRow(p: ProductDetail): Array<string | number | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pr = (p.pricing ?? {}) as Record<string, any>;
const raw = (p.raw ?? {}) as Record<string, unknown>;
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? p.name ?? '').trim();
const scale = p.dimension_unit === 'cm' ? 1 : 0.1;
const dims =
p.depth != null && p.width != null && p.height != null
? `${p.depth * scale}×${p.width * scale}×${p.height * scale}`
: '';
const n = (v: unknown, digits = 2) => (v == null || v === '' ? '' : Number(v).toFixed(digits));
return [
p.offer_id || '',
titleZh,
n(pr.purchasePrice),
n(pr.logisticsFee),
n(pr.fullCommission),
n(pr.receivedPrice),
n(pr.totalCost),
n(pr.sellingPriceCny),
n(pr.profitPrice),
pr.profitRate != null ? pr.profitRate : '',
n(pr.sellingPriceRub, 0),
p.weight ?? '',
dims,
STAGE_LABEL[p.stage] ?? p.stage,
p.offer_id ? `https://www.ozon.ru/product/${p.offer_id}` : '',
(raw.purchase_url as string) ?? '',
];
}
/** 组合码一行:`货号 预留₽价(两位)`,无预留价回退销售价₽ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function comboCode(p: ProductDetail): string {
const pr = (p.pricing ?? {}) as Record<string, any>;
const rub = pr.reservedPriceRub ?? pr.sellingPriceRub;
if (!p.offer_id || rub == null) return '';
return `${p.offer_id} ${Number(rub).toFixed(2)}`;
}
/** 拉取全部「已计价」商品详情(分页列表 → 逐个详情;自用规模够用) */
async function fetchAllPricedProducts(): Promise<ProductDetail[]> {
const pageSize = 100;
const details: ProductDetail[] = [];
for (let page = 1; page < 50; page++) {
const res = await listProducts({ page, page_size: pageSize });
for (const item of res.items) {
try {
const d = await getProduct(item.id);
if ((d.pricing as Record<string, unknown> | null)?.calculatedAt) details.push(d);
} catch {
/* 单个失败跳过,不阻断批量导出 */
}
}
if (page * pageSize >= res.total) break;
}
return details;
}
function stamp(): string {
const d = new Date();
const p = (v: number) => String(v).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
interface Props {
product: ProductDetail;
}
export default function TrialExportPanel({ product }: Props) {
const [batchLoading, setBatchLoading] = useState(false);
const [comboOpen, setComboOpen] = useState(false);
const [comboText, setComboText] = useState('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pr = (product.pricing ?? {}) as Record<string, any>;
const exportCurrent = () => {
if (!pr.calculatedAt) {
message.warning('尚未计价,请先在「价格试算」完成参数填写');
return;
}
downloadCsv([CSV_HEADER, buildRow(product)], `试算_${product.offer_id || '商品'}_${stamp()}.csv`);
message.success('已导出本商品 CSV');
};
const exportBatch = async () => {
setBatchLoading(true);
const hide = message.loading('正在汇总已计价商品…', 0);
try {
const list = await fetchAllPricedProducts();
if (list.length === 0) {
message.warning('没有已计价的商品');
return;
}
downloadCsv([CSV_HEADER, ...list.map(buildRow)], `试算导出_${list.length}条_${stamp()}.csv`);
message.success(`已导出 ${list.length}`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
hide();
setBatchLoading(false);
}
};
const copyCurrentCombo = async () => {
const code = comboCode(product);
if (!code) {
message.warning('缺少货号或计价结果(卢布预留价)');
return;
}
const ok = await copyText(code);
ok ? message.success(`已复制:${code}`) : message.error('复制失败,请手动复制');
};
const openBatchCombo = async () => {
setBatchLoading(true);
const hide = message.loading('正在汇总组合码…', 0);
try {
const list = await fetchAllPricedProducts();
const lines = list.map(comboCode).filter(Boolean);
if (lines.length === 0) {
message.warning('没有可用的组合码(需要货号 + 卢布预留价)');
return;
}
setComboText(lines.join('\n'));
setComboOpen(true);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
hide();
setBatchLoading(false);
}
};
return (
<div>
<Descriptions size="small" column={3} style={{ marginBottom: 16 }}>
<Descriptions.Item label="计价入库">
{pr.calculatedAt ? (
<Tag color="green"> {new Date(pr.calculatedAt as string).toLocaleString()}</Tag>
) : (
<Tag color="orange"></Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="货号">{product.offer_id || '—'}</Descriptions.Item>
<Descriptions.Item label="俄文标题">
<Text ellipsis={{ tooltip: product.name }} style={{ maxWidth: 220 }}>
{product.name || '—'}
</Text>
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button type="primary" icon={<DownloadOutlined />} onClick={exportCurrent}>
CSV
</Button>
<Button icon={<FileTextOutlined />} loading={batchLoading} onClick={exportBatch}>
CSV
</Button>
<Button icon={<CopyOutlined />} onClick={copyCurrentCombo}>
</Button>
<Button loading={batchLoading} onClick={openBatchCombo}>
</Button>
</Space>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
`货号 预留₽价`退 Ozon
CSV 16 v1 UTF-8 BOM Excel
</Text>
<Modal
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
open={comboOpen}
onCancel={() => setComboOpen(false)}
footer={[
<Button
key="copy"
type="primary"
onClick={async () => {
const ok = await copyText(comboText);
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
}}
>
</Button>,
<Button key="close" onClick={() => setComboOpen(false)}>
</Button>,
]}
>
<Input.TextArea rows={12} readOnly value={comboText} />
</Modal>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { useState } from 'react';
import { Col, Collapse, Input, InputNumber, message, Row, Typography } from 'antd';
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { copyText } from '@/utils/file';
import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
const { Text } = Typography;
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => void;
}
/** 包装尺寸 mm → cm 展示 */
function dimsCm(product: ProductDetail): { l: number | null; w: number | null; h: number | null } {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
const cv = (v: number | null) => (v == null ? null : Number((v * scale).toFixed(2)));
return { l: cv(product.depth ?? null), w: cv(product.width ?? null), h: cv(product.height ?? null) };
}
/**
* 01 商品信息:采集结果核对/补录。编辑失焦即落库(父级 PATCH)。
* 重量/尺寸是计价与生图上下文的唯一真源(写 product 包装字段)。
*/
export default function TrialInfoPanel({ product, onSave }: Props) {
const raw = (product.raw ?? {}) as Record<string, unknown>;
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
const [nameRu, setNameRu] = useState(product.name ?? '');
const [offerId, setOfferId] = useState(product.offer_id ?? '');
const [purchaseUrl, setPurchaseUrl] = useState(
(raw.purchase_url as string) ??
(['1688', 'taobao'].includes(product.source_platform ?? '') ? (product.source_url ?? '') : ''),
);
const [desc, setDesc] = useState((raw.desc as string) ?? '');
const [weightG, setWeightG] = useState<number | null>(product.weight ?? null);
const [dims, setDims] = useState(dimsCm(product));
const patchRaw = (patch: Record<string, unknown>) => onSave({ raw: { ...raw, ...patch } });
const readOnly = (v: unknown) => (v ? String(v) : '—');
const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
const openUrl = purchaseUrl?.trim()
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
: '';
return (
<div>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel></FieldLabel>
<Input
value={titleZh}
placeholder="采集原标题可在此整理为中文"
onChange={(e) => setTitleZh(e.target.value)}
onBlur={() => patchRaw({ title_zh: titleZh.trim() })}
/>
</Col>
<Col span={12}>
<FieldLabel></FieldLabel>
<Input
value={nameRu}
onChange={(e) => setNameRu(e.target.value)}
onBlur={() => onSave({ name: nameRu })}
/>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.price)}</Text>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.brand)}</Text>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.sales)}</Text>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.shop)}</Text>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={4}>
<FieldLabel> g</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={weightG}
onChange={(v) => setWeightG(v)}
onBlur={() => onSave({ weight: weightG ?? null, weight_unit: 'g' })}
/>
</Col>
<Col span={3}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.l}
onChange={(v) => setDims((d) => ({ ...d, l: v }))}
onBlur={() =>
onSave({
depth: dims.l == null ? null : dims.l * 10,
dimension_unit: 'mm',
})
}
/>
</Col>
<Col span={3}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.w}
onChange={(v) => setDims((d) => ({ ...d, w: v }))}
onBlur={() =>
onSave({
width: dims.w == null ? null : dims.w * 10,
dimension_unit: 'mm',
})
}
/>
</Col>
<Col span={3}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.h}
onChange={(v) => setDims((d) => ({ ...d, h: v }))}
onBlur={() =>
onSave({
height: dims.h == null ? null : dims.h * 10,
dimension_unit: 'mm',
})
}
/>
</Col>
<Col span={5}>
<FieldLabel>SKU</FieldLabel>
<Input
value={offerId}
placeholder="型号-后缀"
onChange={(e) => setOfferId(e.target.value)}
onBlur={() => onSave({ offer_id: offerId.trim() })}
/>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
{product.source_url ? (
<a href={product.source_url} target="_blank" rel="noreferrer" style={{ lineHeight: '32px' }}>
{product.source_platform || '来源'}
</a>
) : (
<Text style={{ lineHeight: '32px' }}></Text>
)}
</Col>
</Row>
<div style={fieldRowStyle}>
<FieldLabel>1688 / </FieldLabel>
<Input
value={purchaseUrl}
placeholder="https://detail.1688.com/..."
onChange={(e) => setPurchaseUrl(e.target.value)}
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
addonAfter={
<span style={{ display: 'inline-flex', gap: 10 }}>
{openUrl && (
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址">
<ExportOutlined />
</a>
)}
<a
title="复制采买地址"
onClick={() => {
copyText(purchaseUrl).then((ok) => ok && message.success('已复制'));
}}
>
<CopyOutlined />
</a>
</span>
}
/>
</div>
{params.length > 0 && (
<div style={fieldRowStyle}>
<Collapse
ghost
items={[
{
key: 'params',
label: <Text strong> / {params.length} </Text>,
children: (
<div style={{ maxHeight: 260, overflow: 'auto' }}>
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<tbody>
{params.slice(0, 60).map((p, i) => (
<tr key={i} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap', width: 160 }}>
{p.key}
</td>
<td style={{ padding: '4px 0' }}>{p.value}</td>
</tr>
))}
</tbody>
</table>
</div>
),
},
]}
/>
</div>
)}
<div style={{ ...fieldRowStyle, marginBottom: 0 }}>
<FieldLabel></FieldLabel>
<Input.TextArea
rows={5}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onBlur={() => patchRaw({ desc })}
/>
</div>
</div>
);
}
+162
View File
@@ -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<ProductDetail | null>(null);
const [assets, setAssets] = useState<ProductAsset[]>([]);
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<ProductDetail>) => {
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 (
<div style={{ padding: 80, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
}
const raw = (product.raw ?? {}) as Record<string, unknown>;
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
return (
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<Title level={4} style={{ margin: 0 }}>
{titleZh || product.name || '(未命名商品)'}
</Title>
<span style={{ color: STAGE_COLOR[product.stage] }}>
{STAGE_LABEL[product.stage] || product.stage}
</span>
</Space>
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
</div>
<Space size={16} style={{ marginTop: 4 }}>
{product.name && product.name !== titleZh && (
<Text type="secondary" ellipsis={{ tooltip: product.name }} style={{ maxWidth: 420 }}>
{product.name}
</Text>
)}
<Link to={`/product/${product.id}`}> </Link>
</Space>
</div>
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
<div id="section-info">
<Card title="01 商品信息">
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
</Card>
</div>
<div id="section-pricing">
<Card title="02 价格试算">
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
</Card>
</div>
<div id="section-copy">
<Card title="03 俄文文案(AI">
<CopyPanel product={product} onSave={save} />
</Card>
</div>
<div id="section-images">
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
</div>
<div id="section-export">
<Card title="05 入库与导出">
<TrialExportPanel product={product} />
</Card>
</div>
</div>
{/* 右侧区域导航 */}
<div style={{ width: 120, flexShrink: 0 }}>
<div style={{ position: 'sticky', top: 80 }}>
<Menu
mode="inline"
selectedKeys={[active]}
style={{ borderInlineEnd: 0, background: 'transparent' }}
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
onClick={({ key }) => {
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
/>
</div>
</div>
</div>
);
}
@@ -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<ProductDetail>) => void;
}
type PricingPayload = Record<string, any>;
// 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 (
<div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 600, lineHeight: '28px', color: color ?? undefined }}>{value}</div>
{rule && <div style={{ fontSize: 11, color: 'rgba(0,0,0,0.35)' }}>{rule}</div>}
</div>
);
}
/** 销售价格四宫格:人民币/卢布 × 销售价/划线价,价格居中,划线价带删除线 */
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 (
<div style={{ borderTop: '1px dashed #e5e7eb', paddingTop: 14, marginTop: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-</div>
<div style={priceStyle}>{cnySelling}</div>
<div style={noteStyle}></div>
</div>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-</div>
<div style={priceStyle}>{rubSelling}</div>
<div style={noteStyle}>
1 ¥ = {fxRate ? fxRate.toFixed(2) : '--'}
</div>
</div>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-线</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
<div style={noteStyle}>
{reserve}% {cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
</div>
</div>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-线</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
<div style={noteStyle}>
{reserve}% {rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
</div>
</div>
</div>
</div>
);
}
/**
* 02 价格试算:公式与告警完整移植 v1 web/js/app.jstrial-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<LogisticsLevel>((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 (
<Row gutter={24}>
{/* ── 左:操作项(进货价 → 物流等级) ── */}
<Col span={10}>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={purchasePrice}
onChange={(v) => {
const n = v ?? 0;
setPurchasePrice(n);
recalc({ purchasePrice: n });
}}
/>
</Col>
<Col span={12}>
<FieldLabel> %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={profitRate}
onChange={(v) => {
const n = v ?? 0;
setProfitRate(n);
recalc({ profitRate: n });
}}
/>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={tdPrice}
onChange={(v) => {
const n = v ?? 0;
setTdPrice(n);
recalc({ tdPrice: n });
}}
/>
</Col>
<Col span={12}>
<FieldLabel> %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
step={5}
value={reserve}
onChange={(v) => {
const n = v ?? 0;
setReserve(n);
recalc({ reserve: n });
}}
/>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={13}>
<FieldLabel>
¥{' '}
{fxSource && (
<Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>
({fxSource})
</Text>
)}
</FieldLabel>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
min={0}
value={fxRate || undefined}
onChange={(v) => {
const n = v ?? 0;
setFxRate(n);
recalc({ fxRate: n });
}}
/>
<Button icon={<ReloadOutlined />} onClick={refreshFx} title="刷新汇率" />
</Space.Compact>
</Col>
<Col span={11}>
<FieldLabel> / </FieldLabel>
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
</Text>
</Col>
</Row>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Radio.Group
block
optionType="button"
buttonStyle="solid"
value={level}
onChange={(e) => {
const v = e.target.value as LogisticsLevel;
setLevel(v);
recalc({ level: v });
}}
options={[
{ value: 'low', label: '低 (low)' },
{ value: 'high', label: '高 (high)' },
{ value: 'high2', label: 'Premium (high2)' },
]}
/>
</div>
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
/
</Text>
</Col>
{/* ── 右:展示项(物流费 → 销售价) ── */}
<Col span={14}>
{(levelHint || rangeHint) && (
<div style={{ marginBottom: 12 }}>
{levelHint && <Alert type="warning" showIcon message={levelHint} style={{ marginBottom: 8 }} />}
{rangeHint && <Alert type="warning" showIcon message={rangeHint} />}
</div>
)}
<Card size="small" style={{ background: '#fafafa' }}>
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
<Row gutter={[16, 20]}>
<Col span={8}>
<Metric
label="物流费"
value={preview ? `¥ ${num(preview.logisticsFee)}` : '--'}
rule={preview ? `(${logisticsFeeRule(weightGrams(product), level, tdPrice)})` : ''}
/>
</Col>
<Col span={8}>
<Metric
label="平台佣金"
value={preview ? `¥ ${num(preview.commission)}` : '--'}
rule={level === 'low' ? '(销售价 × 12%)' : '(销售价 × 18%)'}
/>
</Col>
<Col span={8}>
<Metric
label="提现费及其他"
value={preview ? `¥ ${num(preview.fullCommission - preview.commission)}` : '--'}
rule="(平台总抽成 − 平台佣金,销售价 × 3.5%)"
color={COLOR_RED}
/>
</Col>
</Row>
{/* 第二行:完全成本 / 净利润 / 实收价 */}
<Row gutter={[16, 20]} style={{ marginTop: 4 }}>
<Col span={8}>
<Metric
label="完全成本"
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
rule="(进货价 + 物流费 + 平台总抽成)"
color={COLOR_RED}
/>
</Col>
<Col span={8}>
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" />
</Col>
<Col span={8}>
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" />
</Col>
</Row>
{/* 第三、四行:人民币/卢布 销售价 + 划线价 四宫格 */}
<SalePriceGrid
cnySelling={preview ? `¥ ${num(preview.sellingPriceCny)}` : '--'}
cnyLine={preview && lineValid ? `¥ ${num(preview.reservedPriceCny)}` : '--'}
rubSelling={preview ? `${num(preview.sellingPriceRub, 0)}` : '--'}
rubLine={preview && lineValid ? `${num(preview.reservedPriceRub, 0)}` : '--'}
reserve={reserve}
fxRate={fxRate}
cnyGap={preview && lineValid ? discountGap : null}
rubGap={preview && lineValid ? preview.reservedPriceRub - preview.sellingPriceRub : null}
/>
</Card>
</Col>
</Row>
);
}
+866
View File
@@ -0,0 +1,866 @@
/**
* 04 图片与 AI 生图(docs/v2.1/image-suite.md):
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Checkbox, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
import { ProductAsset, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import {
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
type PlanItem,
} from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal';
const { Text } = Typography;
const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark';
interface Props {
product: ProductDetail;
assets: ProductAsset[];
onRefreshAssets: () => void;
}
/** 商品信息 → 规划/生成的文本素材(包装字段覆盖回参数表,对齐 image-suite-studio editedTexts */
function buildSuiteTexts(product: ProductDetail): SuiteTextPayload[] {
const raw = (product.raw ?? {}) as Record<string, unknown>;
const texts: SuiteTextPayload[] = [];
const title = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
if (title) texts.push({ kind: 'title', content: title });
if (raw.price) texts.push({ kind: 'price', content: String(raw.price) });
(['brand', 'sales', 'shop'] as const).forEach((k) => {
const v = raw[k];
if (v) texts.push({ kind: k, content: String(v) });
});
const sellingPoint = (raw.selling_point as string) ?? (raw.sellingPoints as string);
if (sellingPoint) texts.push({ kind: 'selling_point', content: sellingPoint });
let pairs = Array.isArray(raw.params) ? [...(raw.params as Array<{ key: string; value: string }>)] : [];
pairs = pairs.filter((p) => !/尺寸|长宽高|重量/i.test(p.key));
if (product.weight != null && product.weight > 0) pairs.push({ key: '重量', value: `${product.weight}g` });
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
if (product.depth != null && product.width != null && product.height != null) {
const d = { l: product.depth * scale, w: product.width * scale, h: product.height * scale };
pairs.push({ key: '产品尺寸', value: `${d.l}×${d.w}×${d.h}` });
}
if (pairs.length) texts.push({ kind: 'params', content: '', pairs });
if (raw.desc) texts.push({ kind: 'desc', content: String(raw.desc) });
return texts;
}
function loadWatermark(): WatermarkPayload {
try {
const s = localStorage.getItem(WATERMARK_STORAGE_KEY);
if (s) return { ...DEFAULT_WATERMARK, ...(JSON.parse(s) as WatermarkPayload) };
} catch { /* 忽略坏数据 */ }
return DEFAULT_WATERMARK;
}
export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Props) {
// 素材选择(默认全选主图 + SKU 图,SKU 规格名供 AI 规划 variant 绑定)
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(assets.filter((a) => ['main', 'sku'].includes(a.group_key) && a.type !== 'video').map((a) => a.id)),
);
/** 上传成功待自动勾选的 asset id */
const pendingSelectRef = useRef<Set<string>>(new Set());
const [uploadingCount, setUploadingCount] = useState(0);
// 出图方案
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map((p) => ({ ...p })));
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
const [planSummary, setPlanSummary] = useState('');
const [planning, setPlanning] = useState(false);
const [planThenGenerate, setPlanThenGenerate] = useState(false);
const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const [exportingZip, setExportingZip] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重置后丢弃迟到的过期响应 */
const planSeqRef = useRef(0);
// 单张 AI 生图弹窗
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video'), [assets]);
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
// 上传完成的素材自动勾选(素材列表刷新后合并)
useEffect(() => {
if (pendingSelectRef.current.size === 0) return;
const next = new Set(selectedKeys);
let touched = false;
pendingSelectRef.current.forEach((id) => {
if (assets.some((a) => a.id === id) && !next.has(id)) {
next.add(id);
touched = true;
}
});
pendingSelectRef.current.clear();
if (touched) setSelectedKeys(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets]);
// 组件卸载停止轮询
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
}, []);
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
const toggleKey = (key: string) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleGroup = (g: string, on: boolean) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
groupImages(g).forEach((a) => (on ? next.add(a.id) : next.delete(a.id)));
return next;
});
};
const uploadButton = (
<Upload
accept="image/*"
multiple
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }) => {
setUploadingCount((c) => c + 1);
try {
const r = await uploadProductAsset(product.id, file as File);
pendingSelectRef.current.add(r.asset_id);
onSuccess?.(r);
} catch (e) {
message.error(`${(file as File).name}${apiErrorMessage(e)}`);
onError?.(e as Error);
} finally {
setUploadingCount((c) => c - 1);
onRefreshAssets();
}
}}
>
<Button icon={<UploadOutlined />} size="small" loading={uploadingCount > 0}>
</Button>
</Upload>
);
// ── 下载采集图片(勾选图打包 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<void> => {
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<string, number> = {};
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: (
<div>
<div>
Ozon · 3:4 {model}
{STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.label} {totalPlanned} {' '}
{selectedKeys.size}
</div>
</div>
),
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<WatermarkPayload>) => {
setWatermark((prev) => {
const next = { ...prev, ...patch };
try {
localStorage.setItem(WATERMARK_STORAGE_KEY, JSON.stringify(next));
} catch { /* 忽略存储失败 */ }
return next;
});
};
const watermarkPopup = (
<div style={{ width: 280 }}>
<Checkbox
checked={watermark.enabled}
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
>
</Checkbox>
{watermark.enabled && (
<>
<div style={{ margin: '10px 0 6px' }}>
<Radio.Group
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(e) => patchWatermark({ type: e.target.value })}
options={[
{ value: 'image', label: '图片徽章' },
{ value: 'text', label: '文字' },
]}
/>
</div>
{watermark.type === 'text' && (
<Input
size="small"
value={watermark.text}
placeholder="水印文字"
onChange={(e) => patchWatermark({ text: e.target.value })}
/>
)}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
%
</Text>
<InputNumber
size="small"
style={{ width: '100%' }}
min={1}
max={100}
step={5}
value={watermark.opacity}
onChange={(v) => patchWatermark({ opacity: v ?? 30 })}
/>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
</Text>
</>
)}
</div>
);
const selectableCount = imgs.length;
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── 采集图片 ── */}
<Card
size="small"
title={`采集图片(已选 ${selectedKeys.size} / ${selectableCount}`}
extra={
<Space>
{uploadButton}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingImages}
onClick={handleExportImages}
>
</Button>
</Space>
}
>
{displayGroups.length === 0 ? (
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
) : (
<Image.PreviewGroup>
{displayGroups.map((g) => (
<div key={g} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Text strong>
{GROUP_LABELS[g] ?? g}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>
{groupImages(g).length}
</Text>
</Text>
<a
style={{ fontSize: 12 }}
onClick={() => {
const all = groupImages(g).every((a) => selectedKeys.has(a.id));
toggleGroup(g, !all);
}}
>
{groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'}
</a>
{g === 'main' && (
<Text type="secondary" style={{ fontSize: 11 }}>
</Text>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id} style={{ width: 112, position: 'relative' }}>
<div
style={{
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: on ? 'rgba(22,119,255,0.06)' : undefined,
width: 'fit-content',
}}
>
<Image
src={assetUrl(a)}
width={100}
height={100}
style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
/>
</div>
<span
title="勾选为参考底图"
onClick={() => 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',
}}
>
</span>
{a.variant_name && (
<Tag
style={{ position: 'absolute', top: 6, right: 6, margin: 0, zIndex: 1 }}
>
{a.variant_name}
</Tag>
)}
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 40, left: 6, margin: 0, zIndex: 1 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
<Button
block
size="small"
style={{ marginTop: 4 }}
icon={<ThunderboltOutlined />}
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
>
AI
</Button>
</div>
);
})}
</div>
</div>
))}
</Image.PreviewGroup>
)}
</Card>
{/* ── 出图方案 ── */}
<Card
size="small"
title={`出图方案(共 ${totalPlanned} 张)`}
extra={
<Space>
{planSource === 'ai' && <Tag color="purple">AI </Tag>}
<Checkbox
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
title="勾选后,AI 规划完成将自动开始生成"
>
</Checkbox>
<Button
size="small"
type="primary"
ghost
icon={<ThunderboltOutlined />}
loading={planning}
onClick={handlePlan}
>
AI
</Button>
</Space>
}
>
<div>
{plan.map((p, idx) => (
<div
key={`${p.kind}-${idx}`}
onClick={() => 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)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Space size={6} wrap>
<Text strong={p.count > 0}>{p.title}</Text>
{p.variant_name && <Tag color="purple">{p.variant_name}</Tag>}
{p.detail && (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: p.detail }}>
{p.detail}
</Text>
)}
{p.prompt_hint && (
<Text type="secondary" style={{ fontSize: 12 }}>🎯 {p.prompt_hint}</Text>
)}
</Space>
</div>
<span onClick={(e) => e.stopPropagation()}>
<InputNumber
size="small"
min={0}
max={5}
value={p.count}
onChange={(v) => setPlanCount(idx, v ?? 0)}
/>
</span>
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0' }}>
<Checkbox
checked={planAllEnabled}
onChange={(e) => togglePlanAll(e.target.checked)}
title="勾选:全部方案至少 1 张(>1 张保留原数量);取消:全部改为 0,方便单独勾选一种方案"
>
</Checkbox>
{planSource === 'ai' && (
<a
style={{ fontSize: 12 }}
onClick={() => {
setPlan(DEFAULT_PLAN.map((p) => ({ ...p })));
setPlanSource('default');
setPlanSummary('');
}}
>
</a>
)}
<Text type="secondary" style={{ fontSize: 12, flex: 1 }} ellipsis={{ tooltip: planSummary }}>
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
</Text>
</div>
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Text style={{ width: 70 }}></Text>
<Segmented
value={styleSet}
onChange={(v) => setStyleSet(v as number)}
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
/>
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
</Button>
</Popover>
</div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{stylePrompts[styleSet] !== undefined && (
<a
style={{ marginLeft: 8 }}
onClick={() =>
setStylePrompts((p) => {
const n = { ...p };
delete n[styleSet];
return n;
})
}
>
</a>
)}
</Text>
<Input.TextArea
rows={3}
value={currentStylePrompt}
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Input.TextArea
rows={2}
value={requirements}
onChange={(e) => setRequirements(e.target.value)}
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
/>
</div>
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}>
{generating && (
<div style={{ flex: 1, minWidth: 200 }}>
<Progress
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
size={['100%', 10]}
status="active"
format={() => `${doneCount}/${suiteTotal}`}
/>
</div>
)}
<Select
style={{ width: 260 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<Button
type="primary"
disabled={selectedKeys.size === 0 || generating || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</Button>
</Row>
</div>
</Card>
{/* ── 生成结果 ── */}
<Card
size="small"
title="生成结果"
extra={
suite && ['done', 'partial'].includes(suite.status) && (
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
ZIP
</Button>
)
}
>
{!suite ? (
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : (
<>
<div style={{ marginBottom: 8 }}>
<Space wrap size={8}>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Tag
color={
suite.status === 'done'
? 'green'
: suite.status === 'failed'
? 'red'
: 'processing'
}
>
{suite.status === 'running'
? '生成中'
: suite.status === 'done'
? '完成'
: suite.status === 'partial'
? '部分失败'
: suite.status === 'pending'
? '排队中'
: '失败'}
</Tag>
<Tag>{suite.ratio} </Tag>
<Tag>{suite.lang}</Tag>
<Tag>{STYLE_SET_OPTIONS.find((s) => s.value === suite.style_set)?.label ?? '自定义'}</Tag>
{suite.total != null && <Tag> {suite.total} </Tag>}
</Space>
</div>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{suite.images
.filter((img) => img.status === 'ok' || img.status === 'failed')
.map((img) => (
<div
key={img.type_id + img.name}
style={{ width: 112, position: 'relative' }}
title={img.error || img.name}
>
{img.status === 'ok' ? (
<Image
src={img.url}
width={100}
height={133}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
) : (
<div
style={{
width: 100,
height: 133,
borderRadius: 6,
background: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ff4d4f',
}}
>
</div>
)}
<div
style={{
fontSize: 11,
color: img.status === 'ok' ? 'rgba(0,0,0,0.45)' : '#ff4d4f',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={img.error || img.name}
>
{img.status === 'failed' && img.error ? img.error : img.name}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
</Card>
{/* 单张 AI 生图弹窗 */}
<AiImageGenModal
open={genModal !== null}
productId={product.id}
source={genModal}
onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()}
/>
</div>
);
}
+75
View File
@@ -82,3 +82,78 @@ export function validateLogisticsLevel(sellingPriceRub: number, level: Logistics
if (sellingPriceRub >= 135 && sellingPriceRub <= 140) return '汇率波动,建议避开 135~140 ₽ 区间';
return '';
}
// ── 以下为 v2.1 试算页从 v1 web/js/app.js:901-991 移植的校验与规则文案 ──────────
// 注意:这三个函数的销售价单位是 CNY(与 v1 一致),不要与上面的 validateLogisticsLevel(₽)混用。
/** 尺寸硬校验:不合规返回错误文案,合规返回 ''(结果卡应显示 -- 且不落计价结果) */
export function validateDimensions(
weightG: number,
dims: { l: number; w: number; h: number },
level: LogisticsLevel,
): string {
const sides = [dims.l, dims.w, dims.h].sort((a, b) => b - a);
const longestSide = sides[0];
const sumOfSides = sides.reduce((s, v) => s + v, 0);
if (level === 'low') {
if (weightG <= 500) {
if (sumOfSides > 90 || longestSide > 60) {
return (
`低等级物流重量≤500g时,要求三边之和≤90厘米且最长边≤60厘米。当前商品三边之和为` +
`${sumOfSides.toFixed(2)}厘米,最长边为${longestSide.toFixed(2)}厘米,不符合要求。`
);
}
} else if (sumOfSides > 150) {
return `低等级物流重量>500g时,要求三边之和≤150厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
} else if (longestSide > 60) {
return `低等级物流重量>500g时,要求最长边≤60厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
}
} else {
if (weightG <= 2000) {
if (sumOfSides > 150) {
return `高等级物流重量≤2000g时,要求三边之和≤150厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
}
if (longestSide > 60) {
return `高等级物流重量≤2000g时,要求最长边≤60厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
}
} else if (sumOfSides > 250) {
return `高等级物流重量>2000g时,要求三边之和≤250厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
} else if (longestSide > 150) {
return `高等级物流重量>2000g时,要求最长边≤150厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
}
}
return '';
}
/** 物流等级建议(销售价单位 CNY) */
export function validateLogisticsLevelCny(sellingPriceCny: number, level: LogisticsLevel): string {
if (sellingPriceCny > 140 && level === 'low') {
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,超过140元,建议选择高等级物流以提供更好的服务体验。`;
}
if (sellingPriceCny < 135 && level !== 'low') {
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,低于135元,建议选择低等级物流以降低成本。`;
}
return '';
}
/** 价格区间提示(销售价单位 CNY) */
export function validatePriceRange(sellingPriceCny: number): string {
if (sellingPriceCny >= 135 && sellingPriceCny <= 140) {
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,处于135-140元的区间。由于汇率波动,建议尽量避免此价格区间。`;
}
return '';
}
/** 物流费规则文案(结果卡展示,含贴单费) */
export function logisticsFeeRule(weightG: number, level: LogisticsLevel, tdPrice: number): string {
let rule = '';
if (level === 'low') {
rule = weightG <= 500 ? 'low3.12 + 0.026×重量' : 'low23.92 + 0.01768×重量';
} else if (level === 'high2') {
rule = weightG <= 5000 ? '高222.88 + 0.026×重量' : '高264.48 + 0.024×重量';
} else {
rule = weightG <= 2000 ? '普通:16.64 + 0.026×重量' : '普通:37.44 + 0.01768×重量';
}
if (tdPrice > 0) rule += ' + 通递价';
return rule;
}
+2
View File
@@ -4,6 +4,7 @@ import AiImagePage from '@/pages/ai-image';
import CollectionPage from '@/pages/collection/CollectionPage';
import ProductEditPage from '@/pages/product/ProductEditPage';
import ShopsPage from '@/pages/shops/ShopsPage';
import TrialPage from '@/pages/trial/TrialPage';
export const router = createBrowserRouter([
{
@@ -13,6 +14,7 @@ export const router = createBrowserRouter([
{ index: true, element: <Navigate to="/collection" replace /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'product/:id', element: <ProductEditPage /> },
{ path: 'trial/:id', element: <TrialPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'ai-image', element: <AiImagePage /> },
],
+195
View File
@@ -0,0 +1,195 @@
/**
* 套图生成 / 单张 AI 生图 / 图片导出 服务层。
* 契约对齐 docs/v2.1/api.mdPhase A 前端先行;带 ★ 的接口待 Phase B 服务端实现)。
* 常量(类型/风格/模型)与 image-suite-studio 的 src/api/client.ts 保持一致。
*/
import { api } from './api';
import apiClient from './api';
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
export const SUITE_TYPE_OPTIONS = [
{ value: 'white_bg', label: '白底主图' },
{ value: 'key_features', label: '核心卖点图' },
{ value: 'selling_pt', label: '卖点图' },
{ value: 'material', label: '材质图' },
{ value: 'lifestyle', label: '场景展示图' },
{ value: 'multi_scene', label: '多场景拼图' },
{ value: 'ecommerce_detail', label: '电商详情图' },
{ value: 'size_chart', label: '尺寸标注图' },
{ value: 'sku_collection', label: 'SKU合集图' },
{ value: 'custom', label: '创意图' },
] as const;
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
export interface PlanItem {
kind: string;
title: string;
detail: string;
prompt_hint: string;
count: number;
variant_name?: string | null;
}
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) => ({
kind: t.value,
title: t.label,
detail: '',
prompt_hint: '',
count: 1,
variant_name: null,
}));
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [
{ value: 1, label: '北欧极简', prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净' },
{ value: 2, label: '清新明亮', prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净' },
{ value: 3, label: '高级感深色', prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级' },
{ value: 4, label: '暖调生活', prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强' },
{ value: 5, label: '纯净棚拍', prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出' },
] as const;
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
};
/** 生图模型(服务端按模型名路由 provider,同 image-suite-studio */
export const IMAGE_MODEL_OPTIONS = [
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
{ value: 'wan2.7-image-pro', label: 'wan2.7-image-pro', desc: '异步精修,质感与细节更强,适合高质量电商大片' },
{ value: 'wan2.6-image', label: 'wan2.6-image', desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好' },
{ value: 'wan2.6-t2i', label: 'wan2.6-t2i', desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快' },
{ value: 'gpt-image-2', label: 'gpt-image-2', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
{ value: 'nano-banana', label: 'nano-banana', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
{ value: 'nano-banana-2', label: 'nano-banana-2', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
{ value: 'nano-banana-pro', label: 'nano-banana-pro', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
] as const;
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2-vip';
export interface WatermarkPayload {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number;
}
export const DEFAULT_WATERMARK: WatermarkPayload = {
enabled: false,
type: 'text',
text: 'xiongmaoyx',
opacity: 30,
};
/** 传给规划/生成的文本素材(kind 与采集 ScanResult.texts 一致) */
export interface SuiteTextPayload {
kind: string;
content: string;
pairs?: Array<{ key: string; value: string }> | null;
}
export interface SuitePlanPayload {
product_id?: string;
texts: SuiteTextPayload[];
sku_variants: string[];
image_stats: Record<string, number>;
platform: string;
requirements?: string | null;
}
export interface SuiteGeneratePayload {
product_id?: string;
texts: SuiteTextPayload[];
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
style_set: number;
style_prompt?: string | null;
requirements?: string | null;
plan: PlanItem[];
platform: string;
model?: string | null;
watermark?: WatermarkPayload;
}
export interface SuiteImageInfo {
type_id: string;
name: string;
url: string;
status: string;
error?: string | null;
}
export interface SuiteInfo {
id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
style_set: number;
platform: string;
lang: string;
ratio: string;
provider?: string;
total?: number;
images: SuiteImageInfo[];
error?: string | null;
}
/** ★ AI 智能规划出图方案 */
export function planSuite(payload: SuitePlanPayload) {
return api.post<{ summary: string; items: PlanItem[] }>('/suite/plan', payload);
}
/** ★ 提交一键生成任务 */
export function generateSuite(payload: SuiteGeneratePayload) {
return api.post<{ suite_id: string }>('/suite/generate', payload);
}
/** ★ 查询套图任务状态(轮询用) */
export function getSuite(suiteId: string) {
return api.get<SuiteInfo>(`/suites/${suiteId}`);
}
/** ★ 下载生成结果 ZIPblob */
export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
const res = await apiClient.get(`/suites/${suiteId}/zip`, { responseType: 'blob' });
return res.data as Blob;
}
export interface ImageEditSinglePayload {
product_id?: string;
image_url: string;
prompt: string;
model: string;
append?: boolean;
}
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
export function imageEditSingle(payload: ImageEditSinglePayload) {
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
}
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
}
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
export async function exportImages(payload: ExportImagesPayload): Promise<Blob> {
const res = await apiClient.post('/export/images', payload, { responseType: 'blob' });
return res.data as Blob;
}
/** 上传补充参考图到商品素材(已有接口:POST /api/materials/bytes */
export async function uploadProductAsset(
productId: string,
file: File,
groupKey = 'upload',
): Promise<{ asset_id: string; status: string }> {
const form = new FormData();
form.append('product_id', productId);
form.append('group_key', groupKey);
form.append('type', 'img');
form.append('file', file);
const res = await apiClient.post('/materials/bytes', form);
return res.data as { asset_id: string; status: string };
}
+57
View File
@@ -0,0 +1,57 @@
/**
* 文件/CSV 工具:v1 web/js/app.js 的 CSV 导出(BOM + RFC 转义)与文件名清理移植。
*/
/** 文件名清理:去掉路径分隔符与常见非法字符 */
export function cleanFilename(name: string): string {
return (name || '').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim();
}
/** 二进制下载(前端环境没有 chrome.downloads,用 <a download> */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/** CSV 单元格转义:含逗号/引号/换行时加引号并双写引号 */
function escapeCell(v: string | number | null | undefined): string {
const s = v == null ? '' : String(v);
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
/** 组装 CSV 文本(UTF-8 BOM,保证 Excel 中文不乱码) */
export function toCsv(rows: Array<Array<string | number | null | undefined>>): string {
return '\uFEFF' + rows.map((row) => row.map(escapeCell).join(',')).join('\r\n');
}
/** CSV 文本下载 */
export function downloadCsv(rows: Array<Array<string | number | null | undefined>>, filename: string): void {
downloadBlob(new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8' }), cleanFilename(filename) || 'export.csv');
}
/** 复制文本到剪贴板(失败时降级选中文案由调用方提示) */
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
return true;
} catch {
return false;
}
}
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pages/trial/aiimagegenmodal.tsx","./src/pages/trial/trialexportpanel.tsx","./src/pages/trial/trialinfopanel.tsx","./src/pages/trial/trialpage.tsx","./src/pages/trial/trialpricingpanel.tsx","./src/pages/trial/trialsuitepanel.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/services/suite.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/file.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}