8 Commits

Author SHA1 Message Date
joey5628 0fa7b6bb8f feat: 修改 400问题 2026-09-28 22:39:54 +08:00
joey5628 7f1503294f feat: AI生图功能丰富 2026-09-27 22:41:33 +08:00
joey5628 1c4fe1a836 feat: 视觉风格修改 2026-09-16 22:03:36 +08:00
joey5628 897e5585c0 feat: 试用过程中的细节优化 2026-09-13 22:19:16 +08:00
R524809 dbb2bb80bd feat: 生图增加商品尺寸填报 2026-09-11 17:28:06 +08:00
R524809 27c5daed72 feat: 更新 2.2 文档 2026-09-02 17:52:05 +08:00
joey5628 d7247ca2da docs: 多规格商品试算编辑 2026-08-31 22:38:59 +08:00
joey5628 8b54f7ed1e fix: 修改一下生图的 bug 2026-08-31 21:56:48 +08:00
65 changed files with 2977 additions and 481 deletions
+2
View File
@@ -37,6 +37,8 @@ ozon-seller-kit/
./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900
```
`start.command` 以单实例方式运行;再次启动时会先停止此前由它管理的同项目进程,再启动新实例。
**2. 构建/加载采集插件**:
```bash
+2 -2
View File
@@ -2,7 +2,7 @@
> 更新:2026-08-31
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`multi-sku-pricing.md`](./multi-sku-pricing.md)(多 SKU 试算与导出方案)、[`multi-sku-measurements.md`](./multi-sku-measurements.md)(**实物规格与包装物流规格的双口径设计**)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
---
@@ -48,7 +48,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits`;对于 `gpt-image-2`,`images` 必须传 **`[{"image_url": "data:image/...;base64,..."}]` 对象数组**。Pcore 通用文档也描述字符串数组,但该模型通道会异步校验 `images[].image_url`,以实际错误为准。均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 需使用模型支持的尺寸或比例。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
## 4. AI 规划张数策略(`planner.py`)
+2
View File
@@ -110,6 +110,8 @@ V2(原计划) V2.1(现在)
| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 |
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`multi-sku-pricing.md`](./multi-sku-pricing.md) | 多 SKU:采集契约、父商品/SKU 数据模型、试算页明细、CSV 导出与分阶段迁移方案 | 扩展规格级计价、导出或采集时读 |
| [`multi-sku-measurements.md`](./multi-sku-measurements.md) | **多 SKU 双规格**:实物规格与包装物流规格的字段、采集、试算、尺寸图、导出和迁移设计 | 改 SKU、尺寸、重量、试算或尺寸图前必读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
| [`background-jobs.md`](./background-jobs.md) | **后台任务中心**:统一任务模型、Worker、状态、恢复、已完成阶段和后续路线图 | 改生图、水印或任何耗时任务前先读 |
| [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 |
+172
View File
@@ -0,0 +1,172 @@
# 多 SKU 的实物规格与包装物流规格设计
> 状态:调研与设计结论,尚未实施。
> 关联:[多 SKU 商品处理与试算页扩展方案](./multi-sku-pricing.md)。本文补充其“尺寸与重量”的双口径模型。
> 核心原则:每个 SKU 可以有独立的实物规格和包装物流规格;两者绝不混用。
---
## 1. 问题与结论
尺寸与重量有两种不同的业务口径:
| 口径 | 数据 | 用途 |
|---|---|---|
| **实物规格** | 商品本体长、宽、高、净重 | 商品规格展示、俄文文案、AI 尺寸标注图 |
| **包装物流规格** | 发货包装长、宽、高、含包装毛重 | 物流尺寸限制、物流费、价格试算、CSV 上品 |
同一个商品还可能有多个 SKU。容量、尺寸、套装数等 SKU 往往在两组数据上都不同;仅颜色不同的 SKU 则通常可继承父商品的两组默认值。
因此目标模型不是“商品加一张 SKU 价格表”,而是:
```text
商品(SPU / 父记录)
├─ 共用:标题、描述、采购链接、图片、文案、计价默认值
├─ 默认实物规格:长宽高、净重
├─ 默认包装物流规格:长宽高、包装毛重
├─ SKU A:规格、两组规格覆盖、采购价、试算结果、货号
├─ SKU B:规格、两组规格覆盖、采购价、试算结果、货号
└─ SKU C:规格、两组规格覆盖、采购价、试算结果、货号
```
SKU 的某个值为空代表继承父商品默认值,不代表零。采集不到或来源未说明口径的“尺寸/重量”必须标记为待确认,不能猜测写入任意一组。
## 2. 当前实现风险
当前 `/trial/:id` 将 `price`、`old_price`、`weight`、`depth/width/height`、`offer_id` 放在单一 `products` 记录中:
- `TrialPricingPanel` 使用重量尺寸做物流费和尺寸校验,且会回填商品价格;一个 SKU 会覆盖另一个 SKU。
- AI 套图规划从采集参数表寻找“尺寸”,无法确认取到的是实物尺寸还是包装尺寸。
- `/api/materials` 的 `_apply_weight_dims` 以“包装重量/重量”“包装长度/长度”等泛关键词解析,通用“产品尺寸”“重量”可能被当成物流规格。
- 图片资产仅保存 `variant_name`,显示名能重复或改名,无法稳定绑定 SKU。
- CSV 目前按商品一行导出,无法表达 SKU 的独立包装数据和报价。
因此不能只给当前价格卡增加输入框,必须先建立 SKU 与双规格的持久化模型。
## 3. 数据模型
### 3.1 命名、单位和继承
数据库统一使用 `mm`、`g`;前端用 `cm`、`g` 编辑。新功能不得再用裸 `weight`、`depth`、`width`、`height` 表达业务规格。
父商品和 `product_variants` 均使用相同、口径明确的字段:
| 字段组 | 字段 | 使用者 |
|---|---|---|
| 实物规格 | `physical_length_mm`、`physical_width_mm`、`physical_height_mm`、`net_weight_g` | 尺寸图、规格展示、文案 |
| 包装物流规格 | `package_length_mm`、`package_width_mm`、`package_height_mm`、`shipping_weight_g` | 试算、物流校验、导出 |
| SKU 身份 | `source_variant_key`、`variant_name`、`specs` | 平台原始 SKU ID、展示规格、颜色/尺寸/容量 |
| 销售与计价 | `source_price`、`purchase_price`、`offer_id`、`barcode`、`logistics_level`、`pricing` | SKU 独立的输入、结果和上品标识 |
| 审计 | `measurements_meta`、`enabled`、时间戳 | 来源、原始文本、确认状态、是否参与导出 |
`measurements_meta` 至少记录两组数据各自的 `status`(`confirmed` / `ambiguous` / `missing`)、来源、原始文本和更新时间。采集提供的数据不是人工确认的事实时,不应伪装为已确认。
SKU 的有效值按“SKU 覆盖 → 父商品默认值”逐字段合并。实物规格与包装物流规格独立继承:实物尺寸相同不代表包装尺寸也相同。
### 3.2 关联和旧字段
- 新增持久化 `product_variants` 表,而不是长期把 SKU 数组放进 `product.pricing` JSON。
- `product_assets` 增加可空 `product_variant_id`;现有 `variant_name` 仅保留展示兼容。
- 父商品 `pricing` 只保存共享默认参数,如汇率、利润率、贴单费和默认物流等级;SKU `pricing` 保存输入快照、有效包装规格、计算结果和计算时间。
- 当前 `products.depth/width/height/weight` 在迁移期间仅是兼容读写别名;双规格字段是新功能唯一事实来源。
## 4. 采集和后端接口
`POST /api/materials` 保持旧契约兼容,并新增可选 `variants`:
```jsonc
{
"source": { "platform": "1688", "itemId": "...", "url": "..." },
"variants": [
{
"source_variant_key": "平台原始 SKU ID",
"variant_name": "白色 / 33 cm",
"specs": { "颜色": "白色", "尺寸": "33 cm" },
"source_price": 28.5,
"physical_measurements": {
"length_mm": 150, "width_mm": 150, "height_mm": 330, "net_weight_g": null,
"status": "confirmed"
},
"package_measurements": {
"length_mm": 180, "width_mm": 180, "height_mm": 380, "shipping_weight_g": 850,
"status": "confirmed"
},
"image_url": "..."
}
]
}
```
规则:
- `source_variant_key` 优先使用来源平台稳定 ID;展示名不能作为幂等键。
- 未采到的字段传 `null`;无法判断口径时保留原始参数、设 `status: "ambiguous"`。
- 只有明确“包装/外箱/发货”口径的参数可写入包装物流规格;明确“商品/产品/本体”的参数可写入实物规格;泛称参数不自动落入任何组。
- 服务端按来源 SKU ID 幂等更新,并把 SKU 图片关联至 `product_variant_id`。
- SKU 使用独立接口,例如 `GET/POST /api/products/{id}/variants`、`PATCH/DELETE /api/products/{id}/variants/{variant_id}`;不要用整份 `ProductUpdate` 承担 SKU 并发编辑。
服务端新增统一的 `PricingService`:前端可即时预览,但保存与导出时必须按“有效包装物流规格”重新计算,避免浏览器传入结果被篡改或前后端公式漂移。
## 5. 前端设计
页面拆成两个清晰区域:
1. **商品规格与尺寸图**:维护实物长宽高和净重;多 SKU 时可维护商品默认值,或为一个 SKU 覆盖。尺寸标注图唯一读取这里的已确认实物尺寸。
2. **价格试算**:维护包装长宽高、包装毛重、进货价、利润率、物流等级、贴单费、汇率;物流校验与价格结果只在此区域展示。
多 SKU 时,价格区使用明细表,列为“规格 / 实物规格摘要 / 包装规格摘要 / 进货价 / 销售价 / 状态”。点击一行打开抽屉或展开编辑器,而不是把八项字段强塞进表格:
```text
实物规格(用于尺寸图) 包装物流规格(用于试算)
长 / 宽 / 高 / 净重 长 / 宽 / 高 / 包装毛重
状态:SKU 覆盖 / 继承 / 待确认 状态:SKU 覆盖 / 继承 / 待确认
```
交互约束:
- 每个字段可以“恢复继承”;空值不是 `0`。
- 修改父商品默认值只影响未覆盖字段;可以将某一组默认规格批量应用给选中的 SKU。
- 包装物流规格缺失/无效时,该 SKU 不能试算和导出,但其他 SKU 不受影响。
- 实物规格缺失时仍可试算/导出,但不能生成有数值的尺寸标注图。
- 父商品列表不应任选一个 SKU 回写 `product.price`;应展示最低价、价格区间,或显式选择代表 SKU。
## 6. AI 尺寸图、导出与迁移
### AI 尺寸图
尺寸标注图是实物信息,不是物流信息。规划器、提示词和任务上下文必须接收显式 `physical_*` 字段,不能从混合原始参数表兜底。
- 多 SKU 实物尺寸不同:生成前必须选择 SKU;任务保存 `product_variant_id` 与实物规格快照。
- 多 SKU 实物尺寸相同:可以使用父商品默认规格生成一张通用图。
- 实物规格为 `missing` 或 `ambiguous`:规划器不安排尺寸图,也不能提交带数值的尺寸图任务。
- 规格修改后,历史图依其快照标记为“基于旧规格生成”。
### CSV 和上品
CSV 从“每商品一行”改为“每启用 SKU 一行”。进货价、包装毛重、包装尺寸和物流等级来自合并后的 SKU 有效值;实物尺寸绝不作为物流尺寸导出。导出前逐 SKU 校验货号、价格和包装物流规格,并列出失败原因。
每 SKU 一个 Ozon offer、只上代表 SKU、或未来平台变体合并,仍是待确认的商业策略;未确认前系统只生成可审阅的 SKU 级 CSV。
### 历史数据迁移
历史商品无 SKU 时视为一个隐式 SKU。把旧 `pricing`、`weight`、`depth/width/height`、`offer_id` 迁为默认 SKU 的**包装物流规格**,因为它们原本用于价格试算;实物规格一律为 `missing`,不得依据旧字段自动生成尺寸图。
## 7. 实施顺序与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| 0. 业务确认 | 明确 Ozon offer 策略、货号规则、尺寸图是否逐 SKU 生成 | 形成 CSV/上品规则 |
| 1. 数据和采集 | SKU 表、父/SKU 双规格字段、迁移、`variants` 契约;收窄泛规格解析 | 未知口径不被伪造成实物或包装数据 |
| 2. 服务端计算 | 继承合并、统一计算/校验、SKU 级保存和查询接口 | 两 SKU、两组规格不相互覆盖 |
| 3. 试算页 | 实物规格区、包装物流试算区、SKU 表和编辑抽屉 | 刷新后继承、状态、价格和告警一致 |
| 4. 导出和图片 | SKU 行 CSV;尺寸图上下文绑定 SKU 与实物快照 | 包装尺寸绝不进入尺寸图 |
至少覆盖:单 SKU 回归、多 SKU 不同价格、仅颜色不同继承两组规格、实物相同包装不同、包装相同实物不同、泛称规格待确认、一个 SKU 导出失败但其他可导出、尺寸图只使用实物规格、SKU 改名但稳定 ID 不变、历史商品迁移。
## 8. 待确认决策
1. Ozon 中多 SKU 是否一律拆成独立 offer,还是要保留变体合并能力?
2. 货号由父商品自动派生,还是必须人工逐 SKU 维护?
3. 哪些采集来源的实物/包装字段可信,哪些只能作为待确认原始信息?
4. 尺寸图是否必须逐 SKU 生成;AI 主图和场景图由谁指定代表 SKU?
5. 父商品在采集箱中展示最低价、价格区间,还是代表 SKU 的价格?
+172
View File
@@ -0,0 +1,172 @@
# 多 SKU 商品处理与试算页扩展方案
> 状态:设计方案,尚未实施。
> 范围:采集 → 采集箱商品 → `/trial/:id` 价格试算 → CSV 导出;Ozon API 直传仍处于冻结状态。
> 目标:同一商品的不同规格可以独立试算,避免以一个 SKU 的价格、重量和尺寸覆盖全部规格。
> 尺寸/重量的实物与包装物流双口径设计见 [`multi-sku-measurements.md`](./multi-sku-measurements.md);该文补充并约束本方案。
---
## 1. 问题与目标
采集到的商品可能是多 SKU 商品:不同颜色、容量、套装、尺寸或型号往往有不同的进货价、包装重量和长宽高。当前试算页将这些字段保存在 `products` 的单一商品记录中,因此只能计算一个结果;若直接在现有页面逐个修改,会覆盖前一个 SKU 的数据,导出也无法还原各规格的价格。
本方案的目标是把商品拆分为「共享商品信息」和「可独立销售的 SKU」,并保持单 SKU 商品的现有操作不变:
```text
商品(SPU / 父记录)
├─ 共用:标题、俄文文案、采购地址、描述、图片、统一计价默认值
├─ SKU A:规格、采购价、重量、尺寸、独立试算结果、货号
├─ SKU B:规格、采购价、重量、尺寸、独立试算结果、货号
└─ SKU C:规格、采购价、重量、尺寸、独立试算结果、货号
```
不应从图片名或规格文案中猜测重量、尺寸和价格。采集不到的值必须保留为空、继承共用默认值,或要求人工补齐。
## 2. 当前现状与限制
目前的 `/trial/:id` 以一条 `products` 记录作为一个可售单元:
- `price`、`old_price`、`weight`、`depth/width/height`、`offer_id` 各只有一个值;
- `product.pricing` 保存一份试算输入和结果,修改后会回填上述商品字段;
- CSV 导出按商品生成一行,无法表达同一商品的多个独立报价;
- 采集侧已能识别 SKU 名称和 SKU 图片;1688 采集状态中也可能含 SKU 价格或规格信息,但当前上报 `/api/materials` 时尚未稳定传递 SKU 级的价格、重量和尺寸。
因此,不能只在现有「02 价格试算」中添加一张前端表格:那样数据仍会落回一份商品字段,刷新、导出和后续上架都会丢失区分。
## 3. 业务边界
### 3.1 父商品与 SKU 的职责
| 层级 | 保存内容 | 原则 |
|---|---|---|
| 商品(父记录) | 标题、描述、采购链接、参数、共用图片、AI 套图、文案、计价默认值 | 一处维护,供所有 SKU 继承 |
| SKU(子记录) | 规格名/规格值、来源 SKU 标识、SKU 图片、进货价、重量、尺寸、物流等级覆盖、货号、独立计价结果、启用状态 | 每个可单独售卖的规格一条 |
仅颜色不同且包装、进货价相同的 SKU 可以完全继承父商品参数;容量、套装数或尺寸不同的 SKU 通常需要各自维护价格和包装数据。
### 3.2 不在本阶段解决的问题
- 不恢复 Ozon Seller API 自动发布;V2.1 当前仍以 CSV / 组合码辅助人工上品为主。
- 不强行将平台的变体关系映射为 Ozon 的变体关系。是否将每个 SKU 建成独立 Ozon offer,需要在导出/上架阶段单独确认。
- 不把 AI 套图按 SKU 数量机械复制。图片规划应以商品卖点和规格差异为依据;多 SKU 图片选择规则另行细化。
## 4. 推荐数据模型
推荐新增持久化的 `product_variants`(或语义等价的 `product_skus`)表,而不是长期将全部 SKU 塞入 `product.pricing` JSON。独立表可以支持单行导出、筛选、校验、后续独立 offer 和稳定的外部 SKU 映射。
建议字段如下:
| 字段 | 用途 |
|---|---|
| `id`、`product_id` | SKU 主键及所属父商品 |
| `source_variant_key` | 来源平台的稳定 SKU 标识;不能只用展示名称匹配 |
| `variant_name`、`specs` | 规格展示名和结构化规格值,如颜色/尺寸/容量 |
| `source_price` / `purchase_price` | 采集到的价格与人工确认后的进货价 |
| `weight_g`、`length_cm`、`width_cm`、`height_cm` | SKU 包装数据;为空时继承父商品默认值 |
| `logistics_level`、`td_price`、`profit_rate` 等覆盖字段 | 仅保存与父商品默认值不同的项 |
| `pricing` | 该 SKU 的计算快照、告警和 `calculated_at` |
| `offer_id`、`barcode` | 导出/人工上品所需的 SKU 级标识 |
| `image_asset_id`、`enabled`、时间戳 | SKU 主图、是否参与试算/导出和审计 |
父商品的 `pricing` 应转为「默认计价参数」,例如汇率、默认利润率、贴单费和物流等级。SKU 计算时按「SKU 覆盖值 → 父商品默认值」合并,再复用现有 `calculatePricing` 和尺寸校验逻辑。
### 为什么不建议仅扩展 JSON
短期可在 `product.pricing.variants` 中暂存数组,但这会让查询、校验、并发更新、CSV 行导出和后续 offer 对应关系都更复杂。若只是验证交互可暂用 JSON;只要计划实际导出多个 SKU,就应迁移到独立表。
## 5. 采集与接口契约
采集上报需要在保留现有单 SKU 字段的同时,可选地提交 `variants`:
```jsonc
{
"source_url": "...",
"title": "...",
"variants": [
{
"source_variant_key": "平台原始 SKU ID",
"variant_name": "白色 / 33 cm",
"specs": { "颜色": "白色", "尺寸": "33 cm" },
"source_price": 28.5,
"weight_g": null,
"dimensions_cm": { "l": null, "w": null, "h": null },
"image_url": "..."
}
]
}
```
约束:
- `source_variant_key` 优先使用平台原始 ID;显示名称可能重复,不能作为唯一键。
- 不同平台可分步补齐字段。未采到的值传 `null`,不伪造默认值。
- 服务端根据来源 SKU 标识做幂等更新;没有 `variants` 的历史采集商品继续按单 SKU 处理。
- 采集图与 SKU 的关系应单独保存,避免仅靠图名在后续页面猜测归属。
## 6. 试算页交互
现有「02 价格试算」建议扩展为两层,单 SKU 商品默认保持当前单卡片体验:
1. **通用参数**:汇率、默认净利率、贴单费、默认物流等级、划线价倍数等,修改后可批量应用到选中的 SKU。
2. **SKU 明细**:每行显示规格、SKU 图片、进货价、重量、长宽高、物流等级、销售价 ¥/₽、划线价、利润和校验告警;可单行编辑、恢复继承、批量填充或只计算已选 SKU。
建议交互规则:
- 值为空表示「继承商品默认值」,而不是零;界面须明确显示继承来源。
- 修改父级默认值只影响未覆盖该字段的 SKU。
- 某 SKU 尺寸/价格无效时,只阻止该 SKU 的结果和导出,不应影响其他 SKU。
- SKU 可被停用;停用项保留历史但不参与默认计算和导出。
- 父商品顶部不应再把任意一个 SKU 的报价写回 `product.price`。过渡期可展示「¥最低价起」或显式指定一个代表 SKU;具体语义必须固定,避免误导。
## 7. 计算、导出与人工上品
每个启用 SKU 独立调用既有计价公式与尺寸校验,使用同一汇率快照。计算结果应与输入一起持久化,便于追溯导出时的报价来源。
CSV 导出应从「每商品一行」改为「每启用 SKU 一行」:
| CSV 字段 | 来源 |
|---|---|
| 商品共用标题、描述、采购地址 | 父商品 |
| 规格、货号、条码 | SKU |
| 进货价、重量、尺寸、物流等级 | SKU 覆盖值或继承后的有效值 |
| 销售价、划线价、利润、告警状态 | SKU 最新试算结果 |
导出前需校验每个启用 SKU 都有货号、有效价格和有效包装数据;失败项应列出原因,不能静默使用父商品或其他 SKU 的结果。
对于 Ozon 人工上品,必须先确认商业策略:
- **每个 SKU 一个 Ozon offer**:CSV 每个 SKU 一行,最易与独立价格和物流数据对应;
- **只上一个代表 SKU**:在试算页显式选择代表 SKU,其余 SKU 仅作采购参考;
- **平台变体合并**:需要重新启用并完成 Ozon 变体属性映射,不属于 V2.1 当前导出主链路。
在策略确认前,系统只应生成可审阅的 SKU 级试算与 CSV,不自动假定平台变体关系。
## 8. 兼容与迁移
- 既有商品无 `product_variants` 时视为一个隐式 SKU,继续使用当前页面和导出逻辑。
- 新采集的多 SKU 商品创建父商品及 SKU 子记录;可选地将第一个/最低价 SKU 作为界面初始代表,但不覆盖其余数据。
- 迁移时把历史 `product.pricing`、重量、尺寸、`offer_id` 复制为一条默认 SKU;保留父商品字段直到所有读取路径完成切换。
- 试算接口和前端应同时兼容单 SKU、隐式 SKU 和已迁移多 SKU,避免一次性迁移造成历史商品不可用。
## 9. 分阶段实施与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| 0. 业务确认 | 确认 Ozon 上品策略、货号规则、何时需要 SKU 独立包装数据 | 形成明确的 offer/CSV 规则 |
| 1. 数据与采集 | 增加 SKU 表、迁移与 `/api/materials` 的 `variants` 契约;1688 优先接入 | 多 SKU 采集后能稳定落库,缺失字段不被伪造 |
| 2. 试算页 | 增加通用参数和 SKU 明细表,逐行计算/校验/保存 | 两个 SKU 互不覆盖;刷新后数据和告警一致 |
| 3. 导出 | CSV 按启用 SKU 出行,提供失败明细 | 多 SKU 商品导出行数、货号、价格和尺寸与明细一致 |
| 4. 后续发布 | 如恢复 API 发布,再设计 Ozon 变体/offer 映射 | 不影响 V2.1 的人工 CSV 主流程 |
至少覆盖以下测试:单 SKU 回归、多 SKU 不同价格、仅颜色不同且继承包装、缺重量/尺寸、一个 SKU 校验失败但其他可导出、来源 SKU 改名但稳定 ID 未变、历史商品迁移。
## 10. 待确认决策
1. Ozon 后台中,多 SKU 是否一律拆成独立 offer,还是需要保留变体合并能力?
2. 货号由父商品后缀自动派生,还是必须人工逐 SKU 录入?
3. 哪些采集来源可视为 SKU 级重量/尺寸可信,哪些仅作为商品级参考?
4. 多 SKU 的 AI 套图是否要指定代表 SKU,或按 SKU 分组生成?
5. 父商品列表中价格展示为最低价、价格区间,还是指定代表 SKU 的价格?
这些决策确定后,再开始阶段 1;它们会直接影响表结构、导出格式和后续上品方式。
+5 -4
View File
@@ -3,6 +3,7 @@
> 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md)
> 参照:`web/ozonSeller.html` + `web/js/app.js`(v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。
> 前端代码:`studio/src/pages/trial/`
> 多 SKU 的后续扩展、采集契约和迁移方案见 [`multi-sku-pricing.md`](./multi-sku-pricing.md)。本文以下计价字段以当前单 SKU 实现为准。
---
@@ -55,11 +56,11 @@
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认100,0-500)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineDiscountRate` 划线折扣率%(默认50,0-99.99)、
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
> 划线价按「划线折扣率」计算:`划线价 = 销售价 ÷ (1 - 划线折扣率)`。例如折扣率 50%、销售价 ¥50 时,划线价为 ¥100。折扣率必须 ≥ 0 且 < 100%。
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
@@ -84,7 +85,7 @@
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
完全成本 = 进货价 + 物流费 + 平台总抽成
预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价
划线价¥ = 销售价 ÷ (1 - 划线折扣率);差额 = 划线价 - 销售价
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
```
@@ -102,7 +103,7 @@
// product.pricing(PriceInfoPanel 字段的超集,向后兼容)
{
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3,
"lineMultiplier": 100, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
"lineDiscountRate": 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, "linePriceCny": 155.02,
+50
View File
@@ -0,0 +1,50 @@
# V2.2 商品 AI 编辑页
> 状态:需求与架构设计,尚未开发。本文及同目录文档优先于 `docs/v2.1/` 中关于商品编辑页、试算页和多 SKU 的旧设计。
## 1. 目标
V2.2 用一张「商品(SPU)」和多个「可售 SKU」表达一个采集商品,重做当前商品编辑页为 **商品 AI 编辑页**。不在旧 `/trial/:id` 上继续叠加多 SKU 功能;旧试算页在 V2.2 开发完成前保持可用但冻结。
编辑页需要支持:
- 维护可扩展的通用属性和 SKU 独立属性;
- 为每个 SKU 管理图片、视频与视频封面;
- 从包装物流规格发起定价,确认后回填 SKU 售价和划线价;
- AI 生成标题、标签、简介等通用内容,以及按 SKU 生成尺寸图和套图;
- 为后续「上架至 Ozon」保留一 SKU 一 offer 的稳定发布模型。
## 2. 已确认决策
| 主题 | 决策 |
|---|---|
| Ozon 可售单元 | 一个本地 SKU 对应一个独立 Ozon `offer_id` 和一个 `/v3/product/import.items[]` 项。 |
| Ozon 变体 | 类目接口的 `is_aspect=true` 属性(如颜色/尺码)作为 SKU 属性提交;不假定 Ozon 会自动把多个 item 合并为一个前台变体组,发布阶段另行真机验证。 |
| 型号和货号 | 型号是父商品的通用属性;同一商品所有 SKU 共用。货号是 SKU 属性,后续可由 AI 生成、用户可编辑。 |
| 尺寸图 | 必须逐 SKU 生成,只读取该 SKU 已确认的实物规格快照。 |
| 计价 | 不在页面常驻展示试算卡;每个 SKU 的「定价」弹窗使用其有效包装物流规格计算,确认后回填售价与划线价。 |
| 图片采集 | 1688、淘宝、天猫当前不能可靠地按 SKU 采集图片。采集主图、详情图作为商品候选素材;已能稳定归属的 SKU 图才绑定 SKU。 |
| 单规格商品 | 单规格商品也必须有一条默认 SKU。SKU 不是“有多规格时才存在”的可选结构;它是定价、媒体选择、货号和未来 Ozon offer 的统一可售单元。 |
| 字段继承 | “SKU 字段为空”只表示该 SKU 的某一可继承字段未覆盖父商品默认值,绝不表示 SKU 记录、SKU 名称或货号为空,更不表示单规格商品。 |
| 动态类目属性 | 先选 Ozon `description_category_id + type_id`,再读取该类目的属性 schema。`is_aspect=true` 默认归入 SKU 变体属性;其他属性默认归入通用属性,但允许业务显式改为 SKU 属性。 |
| 编辑页 | 用新的商品 AI 编辑页替代旧商品编辑页;不修改旧试算页来承载多 SKU。 |
## 3. Ozon 文档依据
仓内 `docs/ozon-seller-api/04-product-import.md` 表明,`/v3/product/import` 的每个 `items[]` 都必须提供自己的 `offer_id`、`price`、包装 `depth/width/height/weight` 和图片;尺寸与重量不得为零。一次请求可提交最多 100 个 item。`03-category-attributes.md` 定义 `is_aspect=true` 为区分 SKU 变体的类目属性。
因此,独立 offer 不是仅为本地方便的选择,而是与 Ozon 导入契约相符的最小稳定粒度。现有本地文档没有描述可通过一个变体组 ID 将多个 import item 强制合并,故发布设计应以独立 offer 为事实来源;前台变体合并只能作为后续经验证的增强。
## 4. 文档索引
| 文档 | 内容 |
|---|---|
| [product-ai-editor.md](./product-ai-editor.md) | 页面信息架构、交互和保存行为 |
| [data-model.md](./data-model.md) | SPU/SKU、属性、素材、定价和迁移数据模型 |
| [api.md](./api.md) | 编辑页、SKU、素材、定价与 AI 的服务端契约 |
## 5. 范围与非目标
本期先完成编辑、校验、草稿保存和可审阅的数据模型。按钮「上架至 Ozon」可以先呈现为不可用或提示“后续开发”,不得在本期调用 Ozon。AI 套图抽屉沿用现有方案能力作为设计基线,具体交互与任务契约另行讨论后开发。
CSV、Ozon API 直传、库存、审核轮询和前台变体归并均属于后续发布阶段;但字段与接口必须避免妨碍它们。
+100
View File
@@ -0,0 +1,100 @@
# V2.2 商品 AI 编辑 API
> 状态:契约设计,尚未实现。所有写接口由服务端验证所有权、单位、继承与状态;前端不可提交计算结果冒充服务端结果。
## 1. 采集入库与 SKU 归属
`POST /api/materials` 保持现有商品级采集契约,并新增可选 `variants`。服务端始终落一条父商品和至少一条 SKU:有 `variants` 时逐项创建/更新;没有时创建一条默认 SKU。单规格商品不会省略 SKU 表记录,使编辑页、定价和未来发布永远面对同一模型;字段 `null` 只表示该字段未覆盖默认值。
```jsonc
{
"source": { "platform": "1688", "itemId": "123", "url": "https://..." },
"texts": ["...现有采集文本..."],
"images": ["...现有商品级采集图..."],
"variants": [
{
"sourceVariantKey": "stable-source-sku-id",
"variantName": "白色 / 500 ml",
"specs": { "颜色": "白色", "容量": "500 ml" },
"sourcePrice": 28.5,
"physicalMeasurements": {
"lengthMm": null, "widthMm": null, "heightMm": null, "netWeightG": null,
"status": "missing"
},
"packageMeasurements": {
"lengthMm": null, "widthMm": null, "heightMm": null, "shippingWeightG": null,
"status": "missing"
},
"imageUrls": []
}
]
}
```
服务端按 `platform + itemId + sourceVariantKey` 幂等写入 SKU;改名只更新 `variant_name`,不会创建重复 SKU。只有来源给出可靠 SKU—图片对应关系时,`imageUrls` 才能绑定该 SKU。1688、淘宝、天猫当前没有这种可靠映射时,图片仍作为商品级 `collected_main` / `collected_detail` 候选素材,`imageUrls` 保持空数组。来源口径不明的重量和尺寸不得写入任一 measurements 对象,需保留原始文本并写 `ambiguous`。
## 2. 读取与草稿保存
| 接口 | 用途 |
|---|---|
| `GET /api/products/{product_id}/editor` | 返回父商品、动态属性 schema、SKU 摘要/详情、已选媒体和候选素材统计。 |
| `PUT /api/products/{product_id}/editor-draft` | 原子保存父商品、属性、SKU 新增/修改/排序/停用与媒体选择。响应返回规范化完整编辑数据。 |
| `GET /api/products/{product_id}/validation` | 返回父商品及逐 SKU 的本地上架校验结果。 |
`editor-draft` 使用版本号/ETag 防止两个页面静默互相覆盖;版本冲突返回 `409` 和当前版本摘要。复杂的单一操作也提供下列细粒度接口,供弹窗立即保存使用。
## 3. SKU 与属性
| 接口 | 用途 |
|---|---|
| `POST /api/products/{product_id}/variants` | 创建手工 SKU 或复制 SKU,返回新 ID。 |
| `PATCH /api/products/{product_id}/variants/{variant_id}` | 修改单个 SKU 字段或恢复继承(字段值传 `null`)。 |
| `DELETE /api/products/{product_id}/variants/{variant_id}` | 软删除未发布 SKU;已发布 SKU 改为停用并要求后续发布流程处理。 |
| `PUT /api/products/{product_id}/attributes` | 保存通用属性。 |
| `PUT /api/products/{product_id}/variants/{variant_id}/attributes` | 保存 SKU/变体属性。 |
| `GET /api/products/{product_id}/attribute-schema` | 按当前 `description_category_id + type_id` 返回 Ozon schema、字典选项和 `is_aspect` 标识。 |
服务端通过 Ozon `/v1/description-category/attribute` 获取 schema,并按 `type`、`is_required`、`is_collection`、`max_value_count` 和字典值校验结构化值。`is_aspect=true` 是默认的 `variant` scope;客户端可显式将业务上按 SKU 区分的非 aspect 属性保存为 `variant` scope。服务端不得因该业务覆盖而丢弃属性,但会在发布校验中按 Ozon 最终 schema 提示兼容性问题。来源采集接口以稳定 `source_variant_key` 做 SKU 幂等,而不按 `variant_name` 匹配。
## 4. 定价
| 接口 | 用途 |
|---|---|
| `POST /api/products/{product_id}/variants/{variant_id}/pricing/quote` | 以请求的编辑值和服务端合并后的包装规格计算预览,不写入售价。 |
| `POST /api/products/{product_id}/variants/{variant_id}/pricing/confirm` | 重新计算、校验并保存快照,同时写入 `selling_price`、`old_price`。 |
| `GET /api/products/{product_id}/variants/{variant_id}/pricing` | 读取最新确认快照和可解释的告警。 |
`confirm` 的请求只包含用户可编辑的定价输入;服务端自行读取 SKU 和父商品规格,写入计算结果、公式版本和有效包装规格快照。包装值缺失、`ambiguous`、小于等于零或不满足物流尺寸限制时返回 `422`,不更改原价格。
## 5. 媒体
| 接口 | 用途 |
|---|---|
| `GET /api/products/{product_id}/variants/{variant_id}/media/candidates?group=` | 返回 AI 记录、采集主图、采集 SKU 图、采集详情图;带稳定 SKU 归属的候选项只返回给对应 SKU。 |
| `PUT /api/products/{product_id}/variants/{variant_id}/media` | 原子保存该 SKU 图片/视频/封面选中项及顺序。 |
| `POST /api/products/{product_id}/assets` | 上传商品级或明确指定 SKU 的候选媒体。 |
| `DELETE /api/products/{product_id}/assets/{asset_id}` | 删除未被引用的候选资产;仍被 SKU 选中时返回引用信息并要求先解除选择。 |
资产引用必须使用 `asset_id` 和 `product_variant_id`,不使用图片 URL 或 SKU 名称作为关联键。
## 6. AI
| 接口 | 用途 |
|---|---|
| `POST /api/products/{product_id}/ai/copy` | 输入通用商品上下文,返回标题、标签、简介、富内容等候选草稿;不直接覆盖数据库。 |
| `POST /api/products/{product_id}/ai/model-and-offers` | 为父商品返回型号和每个 SKU 的货号候选;用户确认后通过草稿保存接口写入。 |
| `POST /api/products/{product_id}/ai/suite-plans` | 创建套图规划;请求必须显式给出目标 SKU 集合。 |
| `POST /api/products/{product_id}/variants/{variant_id}/ai/dimension-image` | 为单 SKU 创建尺寸图任务;服务端固化已确认实物规格快照。 |
AI 与生图任务沿用现有持久化后台任务模型。任何任务都要保存 `product_variant_id`,且尺寸图额外保存 `physical_measurements_snapshot`;SKU 改名不会丢失任务归属。
## 7. 后续 Ozon 发布契约
未来 `POST /api/products/{product_id}/publish` 的内部实现应把每个启用 SKU 转换成一个 `/v3/product/import.items[]`:
- 父商品标题、描述、类目、通用属性复制到每个 item;
- SKU 的 `offer_id`、价格、划线价、包装尺寸/重量、已选图片和变体属性写入对应 item;
- 每个 SKU 独立保存 Ozon task item 结果、`ozon_product_id` 和 Ozon SKU;
- 请求按最多 100 个 item 分批,单 SKU 失败不应抹掉其他 item 的状态。
本期不实现该接口;本文仅规定数据与错误归属,避免后续发布阶段重新拆分单商品记录。
+80
View File
@@ -0,0 +1,80 @@
# V2.2 数据库与领域模型
> 状态:设计,尚未创建迁移。数据库单位统一为 `mm` 与 `g`。
## 1. 模型边界
```text
products(父商品 / SPU) 1 ─── N product_variants(可售 SKU / Ozon offer)
│ │
├── product_attributes ├── product_variant_media ── product_assets
├── product_assets(候选池) ├── product_attributes(scope=variant)
└── product_pricing_defaults └── variant_pricing_snapshots
```
保留现有 `products` 主键和采集来源字段,以便采集箱和历史链接继续有效;其单 SKU 价格、尺寸、`offer_id` 字段只作为迁移兼容层,V2.2 写路径不再以它们为事实来源。每个父商品至少有一条 `product_variants` 记录:单规格商品拥有默认 SKU,多规格商品拥有多个 SKU;两者不采用不同的数据结构。
## 2. 父商品 `products`
新增/规范的核心字段:
| 字段 | 说明 |
|---|---|
| `model_code` | 通用型号,所有 SKU 共用;可由 AI 建议,用户可编辑。 |
| `title_zh`、`name`、`description` | 中文标题、俄文标题、俄文简介。 |
| `rich_content` | 已验证的 JSON 富内容。 |
| `barcode` | 当前按业务决策作为通用字段。发布阶段若 Ozon/业务要求 SKU 级条码,再增加显式覆盖字段,不从图片或名称推断。 |
| `purchase_url`、`supply_note` | 采买地址和货源备注。 |
| `description_category_id`、`type_id` | Ozon 类目和商品类型。 |
| `pricing_defaults` | 默认利润率、贴单费、物流等级、划线价倍数、汇率等可继承计价参数。 |
| `package_*_default` | 默认包装物流规格;只作为 SKU 的可继承默认值。 |
| `physical_*_default` | 默认实物规格;只作为 SKU 的可继承默认值。 |
历史 `raw` 继续存原始采集文本/参数,绝不可将口径不明的原始“尺寸/重量”自动写入新规格字段。
## 3. SKU `product_variants`
| 字段组 | 字段 | 说明 |
|---|---|---|
| 身份 | `id`、`product_id`、`source_variant_key`、`variant_name`、`specs`、`sort_order`、`enabled` | `source_variant_key` 与来源平台联合唯一;显示名称可以变化或重复。 |
| 销售 | `offer_id`、`purchase_price`、`selling_price`、`old_price`、`currency_code` | `offer_id` 对启用 SKU 必须唯一;售价和划线价仅由定价确认或人工编辑写入。 |
| 包装物流 | `package_length_mm`、`package_width_mm`、`package_height_mm`、`shipping_weight_g`、`package_measurements_meta` | 用于试算、物流校验和 Ozon import。`null` 表示继承,不表示零。 |
| 实物 | `physical_length_mm`、`physical_width_mm`、`physical_height_mm`、`net_weight_g`、`physical_measurements_meta` | 用于尺寸图、规格展示、文案,绝不用于物流计算。 |
| 发布 | `ozon_product_id`、`ozon_sku`、`publish_state`、`published_at` | 为一 SKU 一 offer 的后续同步预留。 |
| 审计 | `created_at`、`updated_at`、`deleted_at` | SKU 采用软删除,防止丢失历史报价与外部映射。 |
两组 `*_measurements_meta` 至少存每个字段/字段组的 `status`(`confirmed`、`ambiguous`、`missing`)、来源、原始文本、确认时间。有效值按“SKU 覆盖 → 父商品默认值”逐字段合并;两组规格独立合并。这里的 `null` 仅表示某个可继承字段未覆盖父商品默认值;绝不表示 SKU 记录不存在,也不以此判断商品是否为单规格。
## 4. 动态属性 `product_attributes`
类目不同且将来可能持续增加属性,不能靠不断扩展 `products` 列。使用结构化属性表:
| 字段 | 说明 |
|---|---|
| `id`、`product_id`、`product_variant_id` | 通用属性只填 `product_id`;SKU 属性还填所属 `product_variant_id`。 |
| `scope` | `common` 或 `variant`;数据库约束与两个外键一致。 |
| `attribute_key`、`attribute_name` | 本地稳定键和最近一次展示名称。 |
| `ozon_attribute_id`、`complex_id` | 已映射时记录 Ozon 属性身份。 |
| `values` | JSON 数组,保存文本与可选 `dictionary_value_id`,能表达单值、多值、数值与布尔。 |
| `source`、`status` | `collected`/`ai`/`manual` 与 `confirmed`/`ambiguous`/`missing`。 |
动态属性 schema 来自 Ozon:先由父商品的 `description_category_id + type_id` 调用 `/v1/description-category/attribute`,再按返回的 `id`、`type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 和 `is_aspect` 渲染与校验。`dictionary_id > 0` 时,值通过属性值字典/搜索接口确认 `dictionary_value_id` 后保存。
通用属性包括品牌、型号、主题标签及默认的类目共有属性。变体属性包括颜色、颜色名称以及 Ozon schema 标记 `is_aspect=true` 的所有属性;`is_aspect` 是默认归属,不是硬编码限制。用户或采集规则可把某个非 `is_aspect` 属性(例如按容量销售的商品的容量)标记为 `variant` scope,并为每个 SKU 保存不同值。`specs` 仅是来源规格的原始/展示快照,不能替代已确认的属性值。
## 5. 素材与媒体选择
`product_assets` 保存一次资产事实:`product_id`、可空 `product_variant_id`、`media_type`(image/video)、`source_group`(collected_main/collected_sku/collected_detail/generated/upload)、原始/当前 URL、尺寸、生成任务/规格快照及来源信息。若采集平台无法稳定给出 SKU 归属,`product_variant_id` 必须为 `null`。
新增 `product_variant_media` 表保存每个 SKU 的已选媒体,而不是复制资产:`product_variant_id`、`asset_id`、`role`(image/video/video_cover)、`sort_order`、`selected_at`。同一商品级素材可安全地被多个 SKU 选用;同一 SKU 的排序独立。发布时仅取该 SKU 已选图片,并执行 Ozon 的图片数量、HTTPS 可访问性等校验。
## 6. 定价快照
`variant_pricing_snapshots` 记录不可变的确认结果:SKU、输入参数、合并后的包装物流规格、公式版本、结果、告警、汇率、`calculated_at`、确认人/来源。SKU 表可缓存最新快照 ID 以加速展示;导出或上架时服务端必须以有效包装规格重新校验,不能信任浏览器传回的价格结果。
## 7. 迁移原则
1. 每个历史 `products` 记录创建一个默认 SKU,复制原 `offer_id`、价格和 `pricing`。
2. 历史 `weight/depth/width/height` 迁入默认 SKU 的包装物流规格,因为旧字段只服务于试算;实物规格标记 `missing`。
3. 历史素材先保留为商品级候选素材;不根据 `variant_name` 倒推 SKU 绑定。
4. 新读路径优先 `product_variants`;在迁移完成前,旧字段只读兼容,禁止双向覆盖。
+92
View File
@@ -0,0 +1,92 @@
# 商品 AI 编辑页设计
> 状态:需求设计,尚未开发。
## 1. 路由与页面定位
建议以 `/product/:id` 作为新页面路由,直接替换旧商品编辑页,从而保留现有列表、链接和深链兼容性;页面标题为「商品 AI 编辑」。旧页面组件移除前先完成数据迁移和路由切换。`/trial/:id` 不再承接新功能,完成迁移后引导用户使用新页。
页面编辑的是一个父商品和其 SKU 集合。它不是按 SKU 复制的多张商品表单:通用信息只维护一次,SKU 卡片只维护可售单元差异。**单规格商品也创建并展示一张默认 SKU 卡片**,因此所有商品都用相同的定价、素材、货号和发布流程。
## 2. 页面结构
```text
页头:返回 · 商品中文名 / 俄文标题 · 草稿状态 · 来源链接
├─ 商品通用信息
│ ├─ 类目、标题、品牌、型号、主题标签、简介、JSON 富内容
│ ├─ 条形码、采买地址、货源备注
│ └─ 类目属性(由当前 Ozon 类目动态渲染)
├─ SKU 商品
│ ├─ 显示模式:卡片(默认) / 紧凑列表
│ ├─ SKU 卡片 × N
│ └─ 新增、复制、停用、删除 SKU
└─ 固定操作栏
├─ 左:一键生成标题/标签/描述 · AI 套图生成
└─ 右:取消并关闭 · 保存草稿 · 上架至 Ozon(后续)
```
页面不采用现有 onBlur 即写库模式。页面保存一个编辑草稿;「保存草稿」以一个事务提交父商品、变更的 SKU、属性和素材顺序。关闭存在未保存改动时提示确认。定价确认是例外:确认后立刻写入该 SKU 的价格和定价快照,同时在页面草稿中同步结果。
## 3. 通用信息区
以下字段属于父商品,全部 SKU 共享:类目、标题、品牌、型号、主题标签、简介、JSON 富内容、条形码、采买地址、货源备注,以及后续增加的商品级字段。
- 标题应保存中文采集标题和可编辑的俄文发布标题;AI 生成后先填入编辑草稿,由用户检查后随“保存草稿”持久化。
- 类目变更会刷新该类目的 Ozon 属性 schema。已填写但不再属于新类目的属性保留为未映射草稿,不自动带到发布 payload。
- 类目属性不硬编码到前端。前端按后端返回的 Ozon schema 显示必填、字典、数值、多值和变体属性提示:`is_aspect=true` 默认展示在 SKU 属性区,其他属性默认展示在通用区。
- `is_aspect` 是 Ozon 的默认归属依据,不是不可推翻的业务限制。比如容量在某类目不是 `is_aspect`,但采购规格实际按容量拆分时,用户可将它标记为 SKU 属性;保存后该属性在每个 SKU 上独立编辑和发布。
- 「JSON 富内容」保存为经过 JSON 校验的结构化值,而不是一段不可验证文本。
## 4. SKU 区
### 4.1 默认卡片,兼顾紧凑列表
默认使用卡片,方便图片、视频和多组规格编辑;切换到紧凑列表时只改变展示,不改变编辑数据。列表列为:序号、SKU 名称、图片缩略图、视频封面、视频、货号、采购价、售价、划线价、包装规格、实物规格、商品颜色、颜色名称、状态和操作。
卡片显示:
- 头部:序号、SKU 名称、启用状态、复制/删除;
- 媒体:最多展示 3 张缩略图,可在偏好中改为 5 张;视频封面与视频状态单独展示;
- 销售:货号、采购价、售价、划线价,以及「定价」按钮;
- 规格:包装物流(长宽高、包装毛重)和实物(长宽高、净重)两个明确区块;
- 变体属性:商品颜色、颜色名称以及当前类目所有 `is_aspect=true` 的属性。
SKU 名称是展示名,不可作为同步键。每项始终保存来源的稳定 SKU 标识;没有来源标识的手工 SKU 由本地 UUID 标识。SKU 的存在不依赖字段是否已填写:规格字段的空值只代表“未覆盖父商品默认值”,不代表单规格商品或缺少 SKU。
### 4.2 定价弹窗
点击某一 SKU 的「定价」后打开弹窗,默认读取并显示该 SKU 的 **有效包装物流规格**(SKU 覆盖值优先,再读父商品默认值)。弹窗允许修改采购价、利润率、贴单费、物流等级、划线价倍数、汇率和包装规格;不显示为常驻结果卡。
服务端计算并返回物流校验、价格建议、人民币/卢布结果。用户点击“确认定价”后:
1. 保存本次输入和有效包装规格快照;
2. 保存计算结果和计算时间;
3. 将卢布销售价回填 SKU `selling_price`,划线价回填 `old_price`;
4. 若包装规格缺失、待确认或尺寸校验失败,则不得确认。
实物规格绝不进入物流计算。若实物规格缺失,定价仍可完成;但该 SKU 的尺寸图按钮不可用。
### 4.3 SKU 图片编辑弹窗
点击 SKU 的图片区域打开素材编辑弹窗:
```text
左侧:该 SKU 已选图片(可排序、删除;限制由发布规则校验)
右侧:AI 生图记录 | 采集主图 | 采集 SKU 图 | 采集详情图
```
用户从右侧候选素材选择后加入左侧,完成后保存该 SKU 的选图顺序。采集主图和详情图是商品级候选池,可被多个 SKU 选择;采集到且有稳定 SKU 归属的 SKU 图默认只出现在对应 SKU 的候选池。AI 生成结果必须记录生成上下文所绑定的 SKU,不能只凭显示名称匹配。
视频封面和视频沿用相同的“候选资产 + SKU 已选媒体”机制,但不与图片混排。发布时按 Ozon 支持能力再确定其序列化方式。
## 5. AI 操作
- 「一键生成标题、标签、描述等信息」只针对父商品;服务端返回按字段区分的候选内容和依据,前端将选中的结果填入草稿。
- 「AI 套图生成」从左侧抽屉展开。抽屉以现有试算页套图规划、风格、要求、模型、任务进度和结果为基线,但需要显式选择目标 SKU;尺寸图任务必须一 SKU 一任务并固化实物规格快照。
- AI 生成不得覆盖用户已编辑字段或已选图片。覆盖只能由用户在结果应用时显式确认。
## 6. 编辑与校验状态
SKU 卡片状态建议显示为:`草稿`、`待补全`、`可定价`、`已定价`、`可上架`、`已停用`。其中“可上架”仅表示本地校验通过,不代表已调用 Ozon。
本地“可上架”最低条件:父商品类目、俄文标题、简介、必填通用/类目属性齐全;每个启用 SKU 有唯一货号、有效售价、有效包装物流规格、所需图片和 SKU 必填属性。实际 Ozon 发布仍须重新做一次服务器端校验。
@@ -47,11 +47,11 @@ export default defineBackground(() => {
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
if (msg?.action === 'fetchText') {
const headers: Record<string, string> = {};
if (msg.referer) headers['Referer'] = msg.referer;
fetch(msg.url, {
headers,
credentials: msg.credentials ? 'include' : 'omit',
// Referer 是受保护请求头,不能通过 headers 设置;使用 Fetch 的标准选项。
referrer: typeof msg.referer === 'string' ? msg.referer : undefined,
referrerPolicy: 'strict-origin-when-cross-origin',
})
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -300,6 +300,11 @@ const App: React.FC = () => {
}
setReporting(true);
try {
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
// 超过 100 张时,background 会自动分批写入同一个商品,用户无需手动拆分。
const imagesToReport = selectedKeys.size > 0
? allImages.filter(img => selectedKeys.has(img.key))
: allImages;
const data = await send<{ product_id: string }>('reportProduct', {
reportBaseUrl: settings.reportBaseUrl,
payload: {
@@ -310,8 +315,7 @@ const App: React.FC = () => {
collectedAt: result.scannedAt,
},
texts: editedTexts(),
// 全量上报采集图(main/sku/detail/video),试算页需要完整素材
images: result.images.map(img => ({
images: imagesToReport.map(img => ({
groupKey: img.groupKey,
groupName: img.groupName,
variantName: img.variantName ?? null,
+5 -5
View File
@@ -38,27 +38,27 @@ export const STYLE_SET_OPTIONS = [
{
value: 1,
label: '北欧极简',
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
prompt: 'Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction',
},
{
value: 2,
label: '清新明亮',
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
prompt: 'Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction',
},
{
value: 3,
label: '高级感深色',
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
prompt: 'Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction',
},
{
value: 4,
label: '暖调生活',
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
prompt: 'Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction',
},
{
value: 5,
label: '纯净棚拍',
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
prompt: 'Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction',
},
] as const;
+52 -2
View File
@@ -5,6 +5,8 @@
import type { TextMaterial } from '../collector/merge';
export interface ReportProductPayload {
/** 后续批次写入首次上报创建的商品;调用方首次上报无需提供。 */
product_id?: string;
source: {
platform: string;
itemId: string | null;
@@ -22,16 +24,24 @@ export interface ReportProductPayload {
}>;
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
refererOrigin?: string;
/** 多批上报时,仅最后一批启动素材下载,避免同一商品出现并发重复下载。 */
process_assets?: boolean;
}
export interface ReportProductResult {
product_id: string;
stage?: string;
assets_queued?: number;
assets_skipped?: number;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */
export async function reportProduct(
/**
* 后端单次最多接收 100 个素材;插件在此自动拆批,用户无需手动删减或分批上报。
* 所有批次共用首次返回的 product_id,且只在最后一批启动下载队列。
*/
const MATERIALS_BATCH_SIZE = 100;
async function postMaterials(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
@@ -44,3 +54,43 @@ export async function reportProduct(
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
return data as ReportProductResult;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS)。 */
export async function reportProduct(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
const batches = payload.images.length > 0
? Array.from({ length: Math.ceil(payload.images.length / MATERIALS_BATCH_SIZE) }, (_, index) =>
payload.images.slice(index * MATERIALS_BATCH_SIZE, (index + 1) * MATERIALS_BATCH_SIZE),
)
: [[]];
let productId = payload.product_id;
let lastResult: ReportProductResult | null = null;
let assetsQueued = 0;
let assetsSkipped = 0;
for (let index = 0; index < batches.length; index += 1) {
try {
const result = await postMaterials(reportBaseUrl, {
...payload,
product_id: productId,
// 文本仅需在首批写入;后续批次只追加素材,避免覆盖人工编辑的信息。
texts: index === 0 ? payload.texts : [],
images: batches[index],
process_assets: index === batches.length - 1,
});
productId = result.product_id;
lastResult = result;
assetsQueued += result.assets_queued ?? 0;
assetsSkipped += result.assets_skipped ?? 0;
} catch (error) {
const prefix = batches.length > 1 ? `第 ${index + 1}/${batches.length} 批上报失败:` : '';
throw new Error(prefix + (error instanceof Error ? error.message : String(error)));
}
}
if (!lastResult) throw new Error('上报未返回商品信息');
return { ...lastResult, assets_queued: assetsQueued, assets_skipped: assetsSkipped };
}
@@ -30,6 +30,7 @@ export interface State1688 {
videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[];
params: Array<{ key: string; value: string }>;
detailUrl?: string;
}
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
@@ -150,6 +151,13 @@ export function extract1688State(context: unknown): State1688 | null {
temp.topCategoryId ? String(temp.topCategoryId) : '',
].filter(Boolean);
// 图文详情位于独立 CDN,SSR 直接给出地址;无需点击标签或滚动页面。
const description = deepFind(context, 'description');
const candidateDetailUrl = description?.fields?.detailUrl ?? description?.detailUrl;
const detailUrl = typeof candidateDetailUrl === 'string' && /^https:\/\//.test(candidateDetailUrl)
? candidateDetailUrl
: undefined;
if (!title && mainImages.length === 0 && skus.length === 0) return null;
return {
@@ -164,5 +172,6 @@ export function extract1688State(context: unknown): State1688 | null {
videos,
skus,
params,
detailUrl,
};
}
@@ -0,0 +1,141 @@
/**
* 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本;
* 本模块只解析字符串和 HTML,绝不执行远端脚本。
*/
import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url';
import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
const IMAGE_EXTENSION_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)$/i;
const IMAGE_EXTENSION_IN_PATH_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)\//i;
function normalizeEscapedText(raw: string): string {
return raw
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
// 1688/淘宝详情接口常把 HTML 放在 JSON/JS 字符串中。若不还原 \",DOMParser
// 会把它编码为 %22,并可能把后面的真实 https URL 误解析成当前 CDN 的路径。
.replace(/\\(["'])/g, '$1')
.replace(/\\\//g, '/');
}
function trimImageCandidate(value: string): string {
return value
.trim()
.replace(/[),;]+$/, '')
// “...jpg/” 是详情字符串错误拼接的常见形态,不是有效图片路径。
.replace(/(\.(?:avif|bmp|gif|jpe?g|png|webp))\/+$/i, '$1');
}
/**
* 修复被外层 CDN 路径/转义引号包裹的图片地址,例如:
* https://itemcdn.tmall.com/%22https://cbu01.alicdn.com/img/...jpg/
* 仅在检测到两个 URL 且内层是标准图片路径时取内层,正常带签名参数的 URL 保持原样。
*/
function recoverEmbeddedImageUrl(value: string): string | null {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// 非法百分号编码时仍按原文解析。
}
const matches = decoded.match(/https?:\/\/[^\s'"<>\\]+/gi) ?? [];
if (matches.length < 2) return null;
for (const match of matches.reverse()) {
const candidate = trimImageCandidate(match);
try {
const url = new URL(candidate);
if (/^https?:$/i.test(url.protocol) && IMAGE_EXTENSION_RE.test(url.pathname)) return url.href;
} catch {
// 继续检查更外层的候选。
}
}
return null;
}
function normalizeUrl(raw: string, baseUrl: string): string | null {
const cleaned = raw.trim().replace(/^['"]|['"]$/g, '');
const value = recoverEmbeddedImageUrl(cleaned) ?? cleaned;
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
try {
const url = new URL(value, baseUrl);
if (!/^https?:$/i.test(url.protocol)) return null;
// 不允许图片扩展名后仍跟路径段(如 .jpg/),该 URL 不会指向图片资源。
if (IMAGE_EXTENSION_IN_PATH_RE.test(url.pathname)) return null;
return url.href;
} catch {
return null;
}
}
function addUrl(out: string[], seen: Set<string>, raw: string | null, baseUrl: string): void {
if (!raw) return;
const url = normalizeUrl(raw, baseUrl);
if (!url || seen.has(url)) return;
seen.add(url);
out.push(url);
}
/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */
export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const decoded = normalizeEscapedText(raw);
const doc = new DOMParser().parseFromString(decoded, 'text/html');
for (const el of Array.from(doc.querySelectorAll('img, source'))) {
for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl);
const srcset = el.getAttribute('srcset');
if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl);
}
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('[style]'))) {
addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl);
}
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
for (const candidate of candidates) {
const url = normalizeUrl(trimImageCandidate(candidate), baseUrl);
if (!url) continue;
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl);
}
return urls;
}
/** 由 background service worker 跨域读取详情接口。 */
export function fetchDetailText(url: string, referer: string): Promise<string> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{ action: 'fetchText', url, referer, credentials: true },
(response: { ok?: boolean; text?: string; error?: string } | undefined) => {
const runtimeError = chrome.runtime.lastError;
if (runtimeError) return reject(new Error(runtimeError.message));
if (!response?.ok || typeof response.text !== 'string') {
return reject(new Error(response?.error || '详情接口未返回内容'));
}
resolve(response.text);
},
);
});
}
export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] {
const seen = new Set<string>();
const result: ImageMaterial[] = [];
for (const rawUrl of urls) {
const url = toOriginalUrl(rawUrl, profile.originalUrlRules);
const key = dedupeKey(url, profile.originalUrlRules);
if (seen.has(key)) continue;
seen.add(key);
result.push({
key: `detail-${String(result.length + 1).padStart(3, '0')}`,
groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl,
index: result.length, type: 'img',
});
}
return result;
}
+19
View File
@@ -53,6 +53,25 @@ export function queryAllDeep(selectors: string[]): Element[] {
return out;
}
/**
* 淘宝等页面会把图文详情放在延迟挂载的标签页中;仅滚动页面不会触发该标签的内容加载。
* 只匹配明确的详情标签,避免误点商品页中的普通链接或推荐内容。
*/
export async function activateDetailTab(): Promise<void> {
const labels = new Set(['图文详情', '商品详情', '详情']);
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[role="tab"], button, [class*="tab"]'));
const tab = candidates.find((el) => {
const label = (el.textContent || '').replace(/\s+/g, ' ').trim();
const rect = el.getBoundingClientRect();
return labels.has(label) && rect.width > 0 && rect.height > 0 && el.getAttribute('aria-selected') !== 'true';
});
if (!tab) return;
tab.click();
// 给懒加载容器一次渲染机会,再由后续滚动加载实际图片。
await new Promise((resolve) => setTimeout(resolve, 600));
}
/**
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
+86
View File
@@ -0,0 +1,86 @@
/** 小型 MD5 实现,仅用于淘宝公开 H5 接口的请求签名。 */
function add32(a: number, b: number): number { return (a + b) & 0xffffffff; }
function rol(num: number, cnt: number): number { return (num << cnt) | (num >>> (32 - cnt)); }
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
return add32(rol(add32(add32(a, q), add32(x, t)), s), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function cycle(state: number[], block: number[]): void {
let [a, b, c, d] = state;
a = ff(a, b, c, d, block[0], 7, -680876936); d = ff(d, a, b, c, block[1], 12, -389564586);
c = ff(c, d, a, b, block[2], 17, 606105819); b = ff(b, c, d, a, block[3], 22, -1044525330);
a = ff(a, b, c, d, block[4], 7, -176418897); d = ff(d, a, b, c, block[5], 12, 1200080426);
c = ff(c, d, a, b, block[6], 17, -1473231341); b = ff(b, c, d, a, block[7], 22, -45705983);
a = ff(a, b, c, d, block[8], 7, 1770035416); d = ff(d, a, b, c, block[9], 12, -1958414417);
c = ff(c, d, a, b, block[10], 17, -42063); b = ff(b, c, d, a, block[11], 22, -1990404162);
a = ff(a, b, c, d, block[12], 7, 1804603682); d = ff(d, a, b, c, block[13], 12, -40341101);
c = ff(c, d, a, b, block[14], 17, -1502002290); b = ff(b, c, d, a, block[15], 22, 1236535329);
a = gg(a, b, c, d, block[1], 5, -165796510); d = gg(d, a, b, c, block[6], 9, -1069501632);
c = gg(c, d, a, b, block[11], 14, 643717713); b = gg(b, c, d, a, block[0], 20, -373897302);
a = gg(a, b, c, d, block[5], 5, -701558691); d = gg(d, a, b, c, block[10], 9, 38016083);
c = gg(c, d, a, b, block[15], 14, -660478335); b = gg(b, c, d, a, block[4], 20, -405537848);
a = gg(a, b, c, d, block[9], 5, 568446438); d = gg(d, a, b, c, block[14], 9, -1019803690);
c = gg(c, d, a, b, block[3], 14, -187363961); b = gg(b, c, d, a, block[8], 20, 1163531501);
a = gg(a, b, c, d, block[13], 5, -1444681467); d = gg(d, a, b, c, block[2], 9, -51403784);
c = gg(c, d, a, b, block[7], 14, 1735328473); b = gg(b, c, d, a, block[12], 20, -1926607734);
a = hh(a, b, c, d, block[5], 4, -378558); d = hh(d, a, b, c, block[8], 11, -2022574463);
c = hh(c, d, a, b, block[11], 16, 1839030562); b = hh(b, c, d, a, block[14], 23, -35309556);
a = hh(a, b, c, d, block[1], 4, -1530992060); d = hh(d, a, b, c, block[4], 11, 1272893353);
c = hh(c, d, a, b, block[7], 16, -155497632); b = hh(b, c, d, a, block[10], 23, -1094730640);
a = hh(a, b, c, d, block[13], 4, 681279174); d = hh(d, a, b, c, block[0], 11, -358537222);
c = hh(c, d, a, b, block[3], 16, -722521979); b = hh(b, c, d, a, block[6], 23, 76029189);
a = hh(a, b, c, d, block[9], 4, -640364487); d = hh(d, a, b, c, block[12], 11, -421815835);
c = hh(c, d, a, b, block[15], 16, 530742520); b = hh(b, c, d, a, block[2], 23, -995338651);
a = ii(a, b, c, d, block[0], 6, -198630844); d = ii(d, a, b, c, block[7], 10, 1126891415);
c = ii(c, d, a, b, block[14], 15, -1416354905); b = ii(b, c, d, a, block[5], 21, -57434055);
a = ii(a, b, c, d, block[12], 6, 1700485571); d = ii(d, a, b, c, block[3], 10, -1894986606);
c = ii(c, d, a, b, block[10], 15, -1051523); b = ii(b, c, d, a, block[1], 21, -2054922799);
a = ii(a, b, c, d, block[8], 6, 1873313359); d = ii(d, a, b, c, block[15], 10, -30611744);
c = ii(c, d, a, b, block[6], 15, -1560198380); b = ii(b, c, d, a, block[13], 21, 1309151649);
a = ii(a, b, c, d, block[4], 6, -145523070); d = ii(d, a, b, c, block[11], 10, -1120210379);
c = ii(c, d, a, b, block[2], 15, 718787259); b = ii(b, c, d, a, block[9], 21, -343485551);
state[0] = add32(a, state[0]); state[1] = add32(b, state[1]);
state[2] = add32(c, state[2]); state[3] = add32(d, state[3]);
}
function block(input: string): number[] {
const out: number[] = [];
for (let i = 0; i < 64; i += 4) {
out[i >> 2] = input.charCodeAt(i) + (input.charCodeAt(i + 1) << 8)
+ (input.charCodeAt(i + 2) << 16) + (input.charCodeAt(i + 3) << 24);
}
return out;
}
/** 返回 UTF-8 字符串的 32 位小写 MD5。 */
export function md5(input: string): string {
const text = unescape(encodeURIComponent(input));
const state = [1732584193, -271733879, -1732584194, 271733878];
let i = 64;
for (; i <= text.length; i += 64) cycle(state, block(text.slice(i - 64, i)));
const tail = new Array<number>(16).fill(0);
const rest = text.slice(i - 64);
for (let j = 0; j < rest.length; j++) tail[j >> 2] |= rest.charCodeAt(j) << ((j % 4) << 3);
tail[rest.length >> 2] |= 0x80 << ((rest.length % 4) << 3);
if (rest.length > 55) cycle(state, tail);
tail.fill(0, rest.length > 55 ? 0 : 15);
tail[14] = text.length * 8;
cycle(state, tail);
return state.map((word) => {
let out = '';
for (let j = 0; j < 4; j++) out += ((word >> (j * 8)) & 0xff).toString(16).padStart(2, '0');
return out;
}).join('');
}
+1 -1
View File
@@ -83,7 +83,7 @@ export function finalize(
const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张:页面未提供可读取的图文详情数据');
return {
platform: profile.id,
@@ -2,16 +2,16 @@
* 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
* 详情图改为滚动加载后由 DOM 采集补齐。
* ② description.detailUrl:直接读取图文详情,不操作用户页面
* ③ DOM 兜底 + 补充当前已挂载内容
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state';
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
@@ -20,6 +20,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let st: State1688 | null = null;
const detailWarnings: string[] = [];
// ① 桥读 window.context
const keys = await readWindowKeys(['context']);
@@ -75,11 +76,22 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
});
});
source = 'state';
if (st.detailUrl) {
try {
const rawDetail = await fetchDetailText(st.detailUrl, location.href);
const detailImages = detailUrlsToMaterials(extractDetailImageUrls(rawDetail, st.detailUrl), profile);
primaryImages.push(...detailImages);
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
} catch (error) {
detailWarnings.push(`详情接口读取失败: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
detailWarnings.push('页面未提供详情接口地址');
}
}
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
// ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
@@ -90,6 +102,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source);
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -5,7 +5,7 @@
* ③ 站内页 JSON API(entrypoint-api.bx)
* ④ DOM data-widget 选择器兜底 + 详情图补充
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl, toThumbUrl } from '../url';
@@ -148,6 +148,7 @@ export async function scanOzon(profile: SiteProfile, itemId: string | null): Pro
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await activateDetailTab();
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
@@ -3,15 +3,15 @@
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* (isolated world 读不到 window 变量,v1 直读是无效的)
* ② DOM 兜底 + 补充
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
* 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge } from '../taobao-state';
import { collectTaobaoDetailImages } from '../taobao-detail';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
@@ -19,6 +19,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
const detailWarnings: string[] = [];
// ① 桥读页面全局
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
@@ -31,9 +32,20 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
source = 'ssr';
}
// ② DOM 兜底 + 补充
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
// ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。
if (_itemId) {
try {
const detailImages = await collectTaobaoDetailImages(_itemId, profile);
primaryImages.push(...detailImages);
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
} catch (error) {
detailWarnings.push('详情接口读取失败: ' + (error instanceof Error ? error.message : String(error)));
}
} else {
detailWarnings.push('未识别到商品 ID');
}
// ③ DOM 兜底:只读取首屏已挂载内容,不点击详情标签、不滚动页面。
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
@@ -44,6 +56,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, _itemId, texts, images, [], source);
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -139,9 +139,9 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
if (images.length === 0) {
warnings.push('未扫描到任何图片/视频');
}
// SSR 数据里没有详情图,需要 DOM 补充
// SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。
if (stats.detail === undefined) {
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
warnings.push('详情图需补充:页面未提供可读取的图文详情数据');
}
return {
@@ -0,0 +1,56 @@
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from './detail-images';
import { md5 } from './md5';
import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
function getMtopToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/);
if (!match) return null;
try {
return decodeURIComponent(match[1]).split('_')[0] || null;
} catch {
return match[1].split('_')[0] || null;
}
}
function mtopUrl(itemId: string, token: string): string {
const now = Date.now().toString();
const current = new URL(location.href);
const data = JSON.stringify({
abbucket: current.searchParams.get('abbucket') || '',
id: itemId,
ns: current.searchParams.get('ns') || '',
spm: current.searchParams.get('spm') || '',
detail_v: '3.3.0',
preferWireless: 'true',
});
const host = current.hostname === 'detail.tmall.com' ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com';
const url = new URL('https://' + host + '/h5/mtop.taobao.detail.getdesc/7.0/');
url.search = new URLSearchParams({
jsv: '2.7.0', appKey: '12574478', t: now, sign: md5(token + '&' + now + '&12574478&' + data),
api: 'mtop.taobao.detail.getdesc', v: '7.0', isSec: '0', ecode: '0',
AntiFlood: 'true', AntiCreep: 'true', H5Request: 'true', ttid: '2022@tmall_litepc_9.17.0',
type: 'json', dataType: 'json', data,
}).toString();
return url.href;
}
/** 无滚动读取淘宝/天猫图文详情。仅使用浏览器现有登录态,不会改动页面。 */
export async function collectTaobaoDetailImages(itemId: string, profile: SiteProfile): Promise<ImageMaterial[]> {
const token = getMtopToken();
if (!token) throw new Error('未找到淘宝登录态;请登录后重新采集');
const raw = await fetchDetailText(mtopUrl(itemId, token), location.href);
let response: any;
try {
response = JSON.parse(raw);
} catch {
throw new Error('详情接口返回了无效数据');
}
if (!response?.data?.components?.componentData) {
throw new Error(response?.ret?.[0] || '详情接口未返回图文详情');
}
const components = response.data.components.componentData;
const richText = components.desc_richtext_pc?.model?.text;
const source = typeof richText === 'string' ? richText : JSON.stringify(components);
return detailUrlsToMaterials(extractDetailImageUrls(source, location.href), profile);
}
+2
View File
@@ -21,6 +21,8 @@ export default defineConfig({
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://h5api.m.taobao.com/*',
'https://h5api.m.tmall.com/*',
'https://*.alicdn.com/*',
// 1688 详情数据 CDN(description.detailUrl)
'https://itemcdn.tmall.com/*',
+38 -7
View File
@@ -2,8 +2,8 @@
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可
请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。
"""
from __future__ import annotations
@@ -13,6 +13,7 @@ import re
import tempfile
import zipfile
from collections.abc import Iterator
from urllib.parse import quote
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, StreamingResponse
@@ -50,6 +51,7 @@ class ExportImageItem(BaseModel):
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem] = Field(min_length=1)
flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录")
def _clean(name: str) -> str:
@@ -80,6 +82,33 @@ def _is_image(url: str, ctype: str) -> bool:
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
def _image_content_type(data: bytes, url: str, ctype: str) -> str | None:
"""存储对象无扩展名且 MIME 为 octet-stream 时,按图片文件头识别格式。"""
if ctype.split(";")[0].strip().lower().startswith("image/"):
return ctype
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
return "image/webp"
if data.startswith(b"BM"):
return "image/bmp"
return ctype if _is_image(url, ctype) else None
def _download_disposition(filename: str) -> str:
"""生成兼容中文文件名的下载响应头。
Starlette 会按 Latin-1 编码 HTTP 头;直接将中文放进 filename 会抛 UnicodeEncodeError
并返回 500。filename 提供 ASCII 回退,filename* 使用 RFC 5987 的 UTF-8 百分号编码。
"""
fallback = re.sub(r"[^\x20-\x7e]", "_", filename) or "image"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
async def _download(url: str) -> tuple[bytes, str]:
"""本地 media 文件直读磁盘;远程 URL(源站 CDN / 七牛)带 Referer 下载。"""
path = local_path(url)
@@ -126,7 +155,8 @@ async def export_images(req: ExportImagesRequest):
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception: # noqa: BLE001
continue # 单张下载失败不中断整包
if not _is_image(img.url, ctype):
ctype = _image_content_type(data, img.url, ctype)
if ctype is None:
continue
total_source_bytes += len(data)
if total_source_bytes > settings.export_zip_max_bytes:
@@ -148,8 +178,8 @@ async def export_images(req: ExportImagesRequest):
filename = f"{stem}-{n}{ext}"
used.add(filename)
group = _clean(img.groupName) or "图片"
zf.writestr(f"{root}/{group}/{filename}", data)
archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}"
zf.writestr(archive_path, data)
if archive.tell() > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
@@ -180,7 +210,8 @@ async def export_image(img: ExportImageItem):
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=422, detail="图片下载失败") from exc
if not _is_image(img.url, ctype):
ctype = _image_content_type(data, img.url, ctype)
if ctype is None:
raise HTTPException(status_code=400, detail="该地址不是图片")
ext = _ext(img.url, ctype)
@@ -190,5 +221,5 @@ async def export_image(img: ExportImageItem):
return Response(
content=data,
media_type=ctype,
headers={"Content-Disposition": f'attachment; filename="{base}{ext}"'},
headers={"Content-Disposition": _download_disposition(f"{base}{ext}")},
)
+2 -1
View File
@@ -202,7 +202,8 @@ async def create_materials(
await db.commit()
await db.refresh(product)
if queued:
# 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。
if req.process_assets:
background.add_task(process_product_assets, str(product.id))
return MaterialsResponse(
product_id=str(product.id),
+1
View File
@@ -179,6 +179,7 @@ async def list_assets(
"width": a.width,
"height": a.height,
"error": a.error,
"created_at": a.created_at,
}
for a in rows
]
+82 -11
View File
@@ -9,6 +9,7 @@ watermark + services/prompts),任务持久化到数据库,由独立 Worker
from __future__ import annotations
import io
import re
import tempfile
import uuid
import zipfile
@@ -49,6 +50,41 @@ from services.suite_service import texts_to_raw, validate_model
router = APIRouter(prefix="/api", tags=["suite"])
_DIMENSION_PARAM_RE = re.compile(
r"尺寸|长宽高|长度|宽度|高度|厚度|深度|直径|dimension|length|width|height|depth|thickness|diameter",
re.IGNORECASE,
)
def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
"""将已填写的尺寸方向放入提示词上下文;未填写方向绝不传给绘制提示词。"""
if dimensions is None:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
known_dimensions = {
key: value for key, value in {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
}.items() if value is not None and value > 0
}
if not known_dimensions:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
enriched = dict(raw)
# 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 L×W×H,
# 若继续带进 prompt,模型会把未填写的方向也画出来。
params = raw.get("params") or []
if isinstance(params, list):
enriched["params"] = [
pair for pair in params
if isinstance(pair, dict) and not _DIMENSION_PARAM_RE.search(str(pair.get("key") or ""))
]
enriched["dimensions"] = {**known_dimensions, "unit": dimensions.unit}
return enriched
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
@@ -64,7 +100,7 @@ def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iter
@router.post("/suite/plan", response_model=PlanResponse)
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
product_info = texts_to_raw(req.texts)
product_info = _with_dimensions(texts_to_raw(req.texts), req.dimensions)
if not product_info.get("title"):
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
try:
@@ -140,7 +176,7 @@ async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
ratio=spec["ratio"],
style_set=req.style_set,
request_payload={
"context": texts_to_raw(req.texts),
"context": _with_dimensions(texts_to_raw(req.texts), req.dimensions),
"style_prompt": req.style_prompt,
"requirements": req.requirements,
"watermark": req.watermark.model_dump() if req.watermark else None,
@@ -280,12 +316,15 @@ async def download_suite_zip(suite_id: str):
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
settings = get_settings()
provider_name = resolve_provider(req.model, None, settings.image_provider)
structured_edit = bool(req.main_image_url and req.main_image_url.strip())
if structured_edit and req.model not in {"gpt-image-2", "gpt-image-2@pcore"}:
raise HTTPException(status_code=400, detail="单张 AI 编辑仅支持 Pcore gpt-image-2")
provider_name = "pcore" if structured_edit else resolve_provider(req.model, None, settings.image_provider)
if provider_name not in GENERATORS:
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
validate_model(provider_name, req.model)
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
spec = {"lang": req.target_language if structured_edit else "ru", "ratio": req.ratio if structured_edit else "3:4"}
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
product_id = (req.product_id or "").strip() or None
if product_id:
@@ -294,7 +333,29 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
if structured_edit:
ref_urls = list(dict.fromkeys(url.strip() for url in [req.main_image_url, *req.reference_image_urls] if url and url.strip()))
if len(ref_urls) > 3:
raise HTTPException(status_code=400, detail="辅助参考图最多两张")
else:
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
if not ref_urls:
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
if req.mode == "outpaint":
if provider_name != "pcore":
raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2")
if len(ref_urls) != 1:
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
if req.generate_size_chart:
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
if structured_edit and req.ratio != "3:4":
raise HTTPException(status_code=400, detail="仅扩图只支持 3:4")
if structured_edit and req.mode == "edit" and req.background == "reference" and len(ref_urls) < 2:
raise HTTPException(status_code=400, detail="参考图背景需要选择一张辅助参考图")
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart)
task = await create_image_job(
product_id=product_id,
kind="image_edit",
@@ -304,20 +365,30 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
platform="ozon",
lang=spec["lang"],
ratio=spec["ratio"],
style_set=5,
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=req.style_set if structured_edit and req.mode == "edit" else (0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5),
request_payload={
"context": {"title": "AI 精修"},
"style_prompt": None,
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": req.style_prompt if structured_edit else None,
"single_edit": {
"translate_text": req.translate_text,
"target_language": req.target_language,
"background": req.background,
"background_description": req.background_description,
} if structured_edit and req.mode == "edit" else None,
"requirements": req.prompt,
"operation": req.mode,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
"requirements_exclusive": bool(req.mode == "edit" and not is_size_chart and req.prompt.strip()),
"watermark": req.watermark.model_dump() if req.watermark else None,
"append": bool(req.append and product_id),
"after_asset_id": req.after_asset_id,
"ref_images": [{"url": req.image_url, "group_key": "main", "variant_name": None}],
"ref_images": [{"url": url, "group_key": "main", "variant_name": None} for url in ref_urls],
},
item_payloads=[{
"position": 0,
"kind": "custom",
"title": "AI生图",
"kind": "size_chart" if is_size_chart else "custom",
"title": "尺寸标注图" if is_size_chart else ("仅扩图" if req.mode == "outpaint" else "AI生图"),
"detail": "",
"prompt_hint": "",
"variant_name": None,
View File
+1
View File
@@ -33,6 +33,7 @@ class MaterialsRequest(BaseModel):
texts: list[TextMaterial] = Field(default_factory=list)
images: list[ImageMaterial] = Field(default_factory=list)
refererOrigin: str | None = None # 下载源图时需带的 Referer
process_assets: bool = True # 插件多批上报时,仅最后一批触发下载队列
class MaterialsResponse(BaseModel):
+32 -3
View File
@@ -76,6 +76,15 @@ class WatermarkOptions(BaseModel):
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
class ProductDimensions(BaseModel):
"""商品长宽高;尺寸图仅标注实际提供的方向。"""
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
unit: Literal["mm", "cm"] = Field(default="cm", description="尺寸单位")
class PlanItem(BaseModel):
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
@@ -94,6 +103,7 @@ class SuitePlanRequest(BaseModel):
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
platform: str = Field(default="ozon")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于规划尺寸标注图")
class PlanItemOut(BaseModel):
@@ -129,6 +139,7 @@ class SuiteGenerateRequest(BaseModel):
platform: str = Field(default="ozon")
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
class SuiteCreateResponse(BaseModel):
@@ -137,11 +148,13 @@ class SuiteCreateResponse(BaseModel):
class SuiteImageOut(BaseModel):
item_id: str
asset_id: str | None = None
type_id: str
name: str
url: str
status: str
error: str | None = None
completed_at: datetime | None = None
class SuiteOut(BaseModel):
@@ -166,17 +179,33 @@ class SuiteOut(BaseModel):
class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。"""
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
main_image_url: str | None = Field(default=None, description="本次编辑的主图;新界面必须显式传入")
reference_image_urls: list[str] = Field(default_factory=list, max_length=2, description="最多两张辅助参考图,按选择顺序传给模型")
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
model: str = Field(default="nano-banana-2")
mode: Literal["edit", "outpaint"] = Field(
default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
)
model: str = Field(default="gpt-image-2@pcore")
ratio: Literal["3:4", "1:1", "4:5"] = Field(default="3:4", description="输出画幅")
target_language: Literal["ru", "ar", "en"] = Field(default="ru", description="图内文字的目标语言")
translate_text: bool = Field(default=False, description="在本次生图调用中识别、翻译并重新排版图中文字")
style_set: int = Field(default=2, ge=1, le=5)
style_prompt: str | None = Field(default=None, description="用户修改后的风格提示词")
background: Literal["auto", "white", "solid", "scene", "reference"] = Field(default="auto")
background_description: str | None = Field(default=None, description="纯色或场景背景的具体要求")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
default=None,
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
)
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
generate_size_chart: bool = Field(default=False, description="是否按尺寸标注图生成")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
class ImageEditTaskResponse(BaseModel):
+88 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import base64
import io
import logging
import mimetypes
import re
@@ -17,6 +18,7 @@ from dataclasses import dataclass
from typing import Awaitable, Callable
import httpx
from PIL import Image, ImageOps
from config import get_settings
from services.prompts import build_context, build_prompt, type_name
@@ -79,19 +81,21 @@ DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 +
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
if provider == "doubao":
return "1536x2048" if ratio == "3:4" else "2048x2048"
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
if provider == "rightapi":
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
if provider == "pcore":
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
return "1024x1536" if ratio == "3:4" else "1024x1024"
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
return {"3:4": "1152x1536", "4:5": "1280x1600"}.get(ratio, "1024x1024")
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
return "1152*1536" if ratio == "3:4" else "1440*1440"
return {"3:4": "1152*1536", "4:5": "1280*1600"}.get(ratio, "1440*1440")
if ratio == "3:4":
return "1536*2048" if is_wan else "768*1024"
if ratio == "4:5":
return "1536*1920" if is_wan else "960*1200"
return "2048*2048" if is_wan else "1024*1024"
_DOUBAO_ANTI_AI = (
@@ -141,6 +145,75 @@ async def _resolve_ref(url: str) -> str:
return _bytes_to_data_uri(data, mime)
@dataclass(frozen=True)
class OutpaintCanvas:
"""用于受限扩图的预处理结果。
``image_data_url`` 与 ``mask_data_url`` 尺寸严格一致:蒙版不透明的区域是原图,
透明区域才允许供应商生成。最终仍会把 ``original`` 覆盖回结果,防止供应商在
蒙版区域有轻微重绘。
"""
image_data_url: str
mask_data_url: str
original: Image.Image
box: tuple[int, int, int, int]
size: tuple[int, int]
def restore_original(self, generated: bytes) -> bytes:
"""恢复原图区域,确保输出中原图像素不受模型影响。"""
with Image.open(io.BytesIO(generated)) as result:
result = ImageOps.exif_transpose(result).convert("RGBA")
if result.size != self.size:
# 供应商偶发不遵从 size 时仍输出固定 3:4;随后会精确覆盖原图区域。
result = ImageOps.fit(result, self.size, method=Image.Resampling.LANCZOS)
result.alpha_composite(self.original, self.box[:2])
out = io.BytesIO()
result.save(out, format="PNG")
return out.getvalue()
def _png_data_url(image: Image.Image) -> str:
out = io.BytesIO()
image.save(out, format="PNG")
return _bytes_to_data_uri(out.getvalue(), "image/png")
async def prepare_outpaint_canvas(url: str, size: str) -> OutpaintCanvas:
"""把单张参考图置于目标画布,并生成仅开放新增边缘的 PNG alpha 蒙版。"""
try:
target_width, target_height = (int(value) for value in size.lower().split("x", 1))
except (TypeError, ValueError) as exc:
raise ValueError(f"仅扩图需要合法的宽高尺寸,收到: {size}") from exc
if target_width <= 0 or target_height <= 0:
raise ValueError(f"仅扩图需要正数宽高尺寸,收到: {size}")
data, _ = await _resolve_ref_bytes(url)
with Image.open(io.BytesIO(data)) as opened:
source = ImageOps.exif_transpose(opened).convert("RGBA")
if source.width <= 0 or source.height <= 0:
raise ValueError("参考底图尺寸无效")
scale = min(target_width / source.width, target_height / source.height)
source_width = max(1, round(source.width * scale))
source_height = max(1, round(source.height * scale))
original = source.resize((source_width, source_height), Image.Resampling.LANCZOS)
x = (target_width - original.width) // 2
y = (target_height - original.height) // 2
canvas = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
canvas.alpha_composite(original, (x, y))
# OpenAI/GPT Image 兼容蒙版约定:透明像素是可编辑区域;原图区域不透明。
mask = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
mask.paste((255, 255, 255, 255), (x, y, x + original.width, y + original.height))
return OutpaintCanvas(
image_data_url=_png_data_url(canvas),
mask_data_url=_png_data_url(mask),
original=original,
box=(x, y, x + original.width, y + original.height),
size=(target_width, target_height),
)
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
@@ -449,7 +522,8 @@ async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model
kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
# Pcore 的 gpt-image-2 通道实际要求 OpenAI 兼容对象数组。
# 虽然通用文档描述字符串数组也可用,该通道会在异步任务中校验 images[].image_url。
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
@@ -562,6 +636,7 @@ async def advance_generation(
model: str,
provider_task_id: str | None = None,
provider_task_kind: str | None = None,
edit_mask: str | None = None,
) -> GenerationAdvance:
"""推进一次图片生成,不在本函数内无限轮询。
@@ -569,6 +644,8 @@ async def advance_generation(
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
"""
settings = get_settings()
if edit_mask and provider != "pcore":
raise RuntimeError("带蒙版的仅扩图当前仅支持 Pcore gpt-image-2")
if provider == "doubao":
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model))
@@ -649,7 +726,12 @@ async def advance_generation(
kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
# 与 _pcore_request 保持同一经 gpt-image-2 实际验证的形状,避免 Worker 路径漂移。
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
if edit_mask:
# Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。
# 透明区域允许编辑,不透明区域保留。若供应商轻微偏移,Worker 会二次覆盖原图。
body["mask"] = {"image_url": edit_mask}
response = await client.post(
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
)
+81 -3
View File
@@ -6,11 +6,13 @@ import logging
import socket
import uuid
import httpx
from config import get_settings
from db import get_session_factory
from models import ProductAsset
from services.image_assets import get_image_asset_service
from services.generator import _image_size, _refs_for_job, advance_generation
from services.generator import OutpaintCanvas, _image_size, _refs_for_job, advance_generation, prepare_outpaint_canvas
from services.jobs import (
ClaimedItem,
claim_next_item,
@@ -22,6 +24,8 @@ from services.jobs import (
recover_expired_leases,
)
from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block
from services.prompts.single_edit import build_single_edit_prompt
from services.storage import get_storage
from services.suite_service import append_generated_asset
from services.tasks import TaskImage
@@ -30,6 +34,10 @@ from services.video_renderer import VideoRenderCancelled, render_slideshow
log = logging.getLogger("background.worker")
# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。
# 下一次轮询复用同一 task id,不会再次提交/计费。
PROVIDER_POLL_RETRY_DELAY_SECONDS = 30
def _worker_owner() -> str:
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
@@ -152,6 +160,22 @@ async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
await mark_item_failed(claim.item_id, str(exc))
def _outpaint_prompt(requirements: str | None) -> str:
"""仅扩图不复用套图提示词,避免默认风格、营销构图或文字污染边缘生成。"""
requested = (requirements or "").strip()
if not requested:
requested = "Continue the existing surroundings naturally and seamlessly."
return "\n\n".join([
requirements_block(requested),
"OUTPAINT ONLY: Image 1 contains an original photo placed on a 3:4 canvas. Generate pixels only "
"in the transparent masked margins outside the original photo. Do not crop, move, resize, redraw, "
"retouch, recolor, relight, or otherwise alter any pixel of the original photo.",
"Do not add marketing layouts, product captions, overlays, props, or visible text unless the STRICT "
"REQUIREMENTS explicitly ask for them. Make the new margins blend naturally with the original edges.",
"FINAL CHECK: output exactly 3:4 and change only the newly expanded margins.",
])
async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch":
@@ -174,22 +198,56 @@ async def process_item(claim: ClaimedItem) -> None:
"pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model)
try:
single_edit = payload.get("single_edit")
refs = (
[image["url"] for image in payload.get("ref_images") or []]
if single_edit else _refs_for_job(list(payload.get("ref_images") or []), job_item)
)
operation = payload.get("operation", "edit")
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
edit_mask: str | None = None
outpaint: OutpaintCanvas | None = None
if operation == "outpaint":
if len(refs) != 1:
raise RuntimeError("仅扩图必须且只能使用一张参考底图")
prompt = _outpaint_prompt(payload.get("requirements"))
# 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。
if not claim.provider_task_id:
outpaint = await prepare_outpaint_canvas(refs[0], target_size)
refs = [outpaint.image_data_url]
edit_mask = outpaint.mask_data_url
else:
context = build_context(payload.get("context") or {}, fallback_name="")
if single_edit:
prompt = build_single_edit_prompt(
requirements=payload.get("requirements") or "", config=single_edit,
context=context, style_set=claim.style_set,
style_prompt=payload.get("style_prompt"), ratio=claim.ratio,
is_size_chart=job_item.get("kind") == "size_chart",
reference_count=len(refs) - 1,
)
else:
prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
requirements_exclusive=bool(payload.get("requirements_exclusive")),
)
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
is_wan = provider == "tongyi" and model.lower().startswith("wan")
result = await advance_generation(
provider, prompt, refs,
size=_image_size(provider, claim.ratio, is_wan=is_wan, model=model), model=model,
size=target_size, model=model,
provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
edit_mask=edit_mask,
)
if result.data is None:
if not result.provider_task_id or not result.provider_task_kind:
raise RuntimeError("异步生成未返回可恢复的 provider task id")
# 轮询供应商期间用户可能刚好取消任务。此时不能再写回
# waiting_provider,否则父任务已取消、子项却会永久显示“进行中”。
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
await mark_waiting_provider(claim.item_id, result.provider_task_id, result.provider_task_kind)
return
@@ -198,6 +256,11 @@ async def process_item(claim: ClaimedItem) -> None:
await mark_item_cancelled(claim.item_id)
return
if operation == "outpaint":
# 异步任务完成时本轮未必创建过画布,按同一源图与尺寸确定性重建后覆盖原图区域。
outpaint = outpaint or await prepare_outpaint_canvas(refs[0], target_size)
result = type(result)(data=outpaint.restore_original(result.data))
watermark = payload.get("watermark") or {}
position = int(job_item.get("position", 0))
if watermark.get("enabled"):
@@ -231,6 +294,21 @@ async def process_item(claim: ClaimedItem) -> None:
claim.item_id, result_url=result_url, source_url=source_url or None,
watermark_config=watermark_config, generated_asset_id=asset_id,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if claim.provider_task_id and claim.provider_task_kind:
log.warning(
"供应商轮询网络暂时失败,%ds 后重试同一任务 %s:%s",
PROVIDER_POLL_RETRY_DELAY_SECONDS, claim.provider_task_id, exc.__class__.__name__,
)
await mark_waiting_provider(
claim.item_id,
claim.provider_task_id,
claim.provider_task_kind,
delay_seconds=PROVIDER_POLL_RETRY_DELAY_SECONDS,
)
return
log.exception("后台图片任务 %s/%s 网络请求失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc) or exc.__class__.__name__)
except Exception as exc: # noqa: BLE001
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
+18 -2
View File
@@ -206,11 +206,26 @@ async def claim_next_item(owner: str, lease_seconds: int = 90) -> ClaimedItem |
async def mark_waiting_provider(item_id: str, provider_task_id: str, provider_task_kind: str, delay_seconds: int = 3) -> None:
"""先持久化外部任务 ID,再安排下一次查询,是避免重启重复扣费的关键提交点。"""
"""持久化外部任务 ID 并安排下一次查询。
取消可能恰好发生在 Worker 轮询供应商的网络请求期间。这里在同一事务内
再次检查父任务,避免已取消任务被写回 waiting_provider 后永远不再被领取。
"""
async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None:
return
job = await db.get(BackgroundJob, item.job_id)
if job is None or job.cancel_requested:
item.status = ITEM_CANCELLED
item.lease_owner = None
item.lease_expires_at = None
item.next_run_at = None
item.completed_at = utcnow()
if job is not None:
await _refresh_job_status(db, job)
await db.commit()
return
item.provider_task_id = provider_task_id
item.provider_task_kind = provider_task_kind
item.status = ITEM_WAITING_PROVIDER
@@ -474,11 +489,12 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
"images": [
{
"asset_id": str(item.generated_asset_id) if item.generated_asset_id else None,
"type_id": (item.payload or {}).get("kind", "custom"),
"item_id": str(item.id),
"name": (item.payload or {}).get("title", "AI 生图"),
"url": item.result_url or "", "status": item.status,
"error": item.error,
"error": item.error, "completed_at": item.completed_at,
}
for item in items
],
+161 -20
View File
@@ -23,10 +23,19 @@ ALLOWED_KINDS = [
]
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
PLAN_SAFETY_MAX = 15
PLAN_REQUIREMENTS_MAX = 24
# AI 智能规划最多生成 8 张;即使用户在要求中指定更高张数也不突破此上限。
PLAN_SAFETY_MAX = 8
PLAN_REQUIREMENTS_MAX = 8
MAX_VARIANT_MAIN_IMAGES = 2
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
# 因此均视作同一类覆盖,而不是强迫模型只使用 material。
SCENE_KINDS = {"lifestyle", "multi_scene"}
DETAIL_KINDS = {"material", "key_features", "selling_pt", "ecommerce_detail"}
_DIMENSION_PATTERN = re.compile(
r"尺寸|长宽高|直径|厚度|(?:长|宽|高)\s*[::×x*]?\s*\d+|\d+(?:\.\d+)?\s*(?:厘米|毫米|cm|mm|inch|in)\b",
re.IGNORECASE,
)
def _total_budget(requirements: str | None) -> int:
@@ -43,16 +52,16 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,最多 8 张;若「生图要求」明确指定了张数,按指定张数输出但仍不得超过 8 张。
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. 先保证 SKU 主图(规则 2),其余类型(场景/细节/尺寸/合集等)按商品特点自行取舍并分配张数,追求信息效率,不为凑数出图。
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
3. 场景图(kind=lifestyle):按核心使用场景出 1-3 张,每张聚焦一个场景。
4. 细节图(kind=material 或 custom):按关键细节/材质/结构出 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
6. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的任一方向尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含任一可靠尺寸时必须出 1 张;只标注已知方向,无可靠尺寸参数时不要虚构尺寸。
6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
@@ -114,6 +123,144 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
return items
def _has_dimensions(product_info: dict) -> bool:
"""有任一可靠尺寸即可规划尺寸图;未给出的方向不允许模型编造。"""
dimensions = product_info.get("dimensions") or {}
if isinstance(dimensions, dict) and any(dimensions.get(key) is not None for key in ("length", "width", "height")):
return True
for param in product_info.get("params") or []:
if not isinstance(param, dict):
continue
text = f"{param.get('key') or ''} {param.get('value') or ''}"
if _DIMENSION_PATTERN.search(text):
return True
return False
def _requires_every_sku(requirements: str | None) -> bool:
"""用户显式要求逐 SKU 出图时,保留其优先级,不施加代表款上限。"""
if not requirements:
return False
return bool(re.search(
r"(?:每个|全部|所有|逐个|分别).{0,10}(?:sku|SKU|规格|款式|颜色|变体)",
requirements,
))
def _coverage_group(kind: str) -> str:
if kind == "white_bg":
return "main"
if kind in SCENE_KINDS:
return "scene"
if kind in DETAIL_KINDS:
return "detail"
if kind == "size_chart":
return "size"
return "other"
def _total(items: list[dict]) -> int:
return sum(item["count"] for item in items)
def _group_total(items: list[dict], group: str) -> int:
return sum(item["count"] for item in items if _coverage_group(item["kind"]) == group)
def _remove_one(items: list[dict], predicate) -> bool:
"""从后往前删除一个生成槽位,优先保留模型较早规划的构图。"""
for index in range(len(items) - 1, -1, -1):
if not predicate(items[index]):
continue
if items[index]["count"] > 1:
items[index]["count"] -= 1
else:
items.pop(index)
return True
return False
def _make_room(items: list[dict], budget: int, minimums: dict[str, int]) -> bool:
"""为必需图类腾出一张,先去除非核心图或某类的冗余张数。"""
candidates: list[tuple[int, int]] = []
for index, item in enumerate(items):
group = _coverage_group(item["kind"])
if _group_total(items, group) <= minimums.get(group, 0):
continue
# other 最低优先;同类冗余时从后面的项开始删。
priority = 0 if group == "other" else 1
candidates.append((priority, -index))
if not candidates:
return False
_, neg_index = min(candidates)
index = -neg_index
item = items[index]
if item["count"] > 1:
item["count"] -= 1
else:
items.pop(index)
return True
def _balanced_items(
items: list[dict],
sku_variants: list[str],
product_info: dict,
requirements: str | None,
budget: int,
) -> list[dict]:
"""将 LLM 方案约束为信息均衡的商品套图。
提示词能改善规划倾向,但不能保证每次输出。这里把 SKU 主图总量限制为
代表款数量,并在预算允许时补齐核心图类,避免 SKU 多时挤掉场景/细节/尺寸图。
"""
balanced = [dict(item) for item in items if item["count"] > 0]
if not _requires_every_sku(requirements):
max_mains = 1 if len(sku_variants) <= 1 else MAX_VARIANT_MAIN_IMAGES
while sum(item["count"] for item in balanced if item["kind"] == "white_bg") > max_mains:
_remove_one(balanced, lambda item: item["kind"] == "white_bg")
required: list[tuple[str, dict]] = [
("main", {
"kind": "white_bg", "title": "白底主图", "detail": "商品白底主视觉",
"prompt_hint": "front view on pure white background", "count": 1,
"variant_name": sku_variants[0] if sku_variants else None,
}),
("scene", {
"kind": "lifestyle", "title": "使用场景", "detail": "展示商品实际使用场景",
"prompt_hint": "product in a natural everyday use setting", "count": 1,
"variant_name": None,
}),
("detail", {
"kind": "material", "title": "细节展示", "detail": "突出材质与结构细节",
"prompt_hint": "close-up of authentic material and construction details", "count": 1,
"variant_name": None,
}),
]
if _has_dimensions(product_info):
required.append(("size", {
"kind": "size_chart", "title": "尺寸标注", "detail": "展示已采集的商品尺寸",
"prompt_hint": "clear size chart using known product dimensions", "count": 1,
"variant_name": None,
}))
# 用户明确要求较小张数时,优先保留前面的核心图类;不突破其指定预算。
required = required[:budget]
minimums = {group: 1 for group, _ in required}
for group, fallback in required:
if _group_total(balanced, group) > 0:
continue
while _total(balanced) >= budget and _make_room(balanced, budget, minimums):
pass
if _total(balanced) < budget:
balanced.append(fallback)
while _total(balanced) > budget and _make_room(balanced, budget, minimums):
pass
return balanced
def _repair_truncated(s: str) -> dict | None:
"""截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。
@@ -234,16 +381,10 @@ async def generate_plan(
items = _normalize_items(data.get("items") or [], sku_variants)
if items:
# 总量保护:只防跑飞不干预正常规划。生图要求指定「N 张」时按 N 放宽上限
# 先压缩多 SKU 的重复主图、补齐核心视觉类别,再执行总量保护。
# 生图要求指定「N 张」时按 N 放宽上限,并始终不突破该预算。
budget = _total_budget(requirements)
total = sum(i["count"] for i in items)
while total > budget and items:
last = items[-1]
if last["count"] > 1:
last["count"] -= 1
else:
items.pop()
total = sum(i["count"] for i in items)
items = _balanced_items(items, sku_variants, product_info, requirements, budget)
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
last_error = RuntimeError("规划器未返回有效方案项")
+2 -1
View File
@@ -36,10 +36,11 @@ def prompt_family(provider: str, model: str | None) -> str:
def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int,
lang: str, extra: dict | None = None, style_prompt: str | None = None,
requirements: str | None = None) -> str:
requirements: str | None = None, requirements_exclusive: bool = False) -> str:
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
return module.build_prompt(
type_id, ctx, style_set, lang,
extra=extra, style_prompt=style_prompt, requirements=requirements,
requirements_exclusive=requirements_exclusive,
)
+14 -3
View File
@@ -96,12 +96,13 @@ def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
f"on light background, with thin measurement annotation lines (arrows) only for supplied dimensions, "
f"measurement values rendered next to each line"
+ (f" (known specs: {dims})" if dims else "")
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
+ f". {annotation} Small caption row, precise technical drawing aesthetic. {style['tone']}. "
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
@@ -136,7 +137,8 @@ _PROMPT_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的完整生图 prompt(主体参考语义)。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
@@ -145,6 +147,15 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
声明覆盖一切冲突指令,用户可在此输入强制要求。
"""
if requirements_exclusive:
return " ".join([
requirements_block(requirements),
"Execute only the strict requirements above. Do not add marketing layouts, captions, overlays, "
"props, default studio styling, or visible text unless explicitly requested.",
"Preserve the reference image exactly unless the strict requirements explicitly request a "
"specific change. When they do, make only that requested change and preserve everything else.",
"Do not make any change that was not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
if type_id == "custom":
+34 -7
View File
@@ -12,27 +12,27 @@ import re
STYLE_SETS: dict[int, dict] = {
1: {
"name": "北欧极简",
"tone": "Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition",
"tone": "Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "",
},
2: {
"name": "清新明亮",
"tone": "Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition",
"tone": "Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "",
},
3: {
"name": "高级感深色",
"tone": "Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood",
"tone": "Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "",
},
4: {
"name": "暖调生活",
"tone": "Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere",
"tone": "Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "",
},
5: {
"name": "纯净棚拍",
"tone": "Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent",
"tone": "Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction",
"bg": "",
},
}
@@ -133,6 +133,29 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
if len(selling_points) >= 5:
break
dimensions = raw.get("dimensions") or {}
known_dimensions: list[tuple[str, object]] = []
if isinstance(dimensions, dict):
known_dimensions = [
(label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
if dimensions.get(key) is not None
]
unit = (dimensions.get("unit") or "cm") if isinstance(dimensions, dict) else "cm"
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
known_labels = [label for label, _ in known_dimensions]
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
dimension_annotation_instruction = (
"STRICT MEASUREMENT OUTPUT RULE: render exactly "
+ str(len(known_labels))
+ " measurement arrows and labels, only for: "
+ ", ".join(known_labels)
+ ". The only permitted dimension values are: "
+ dimensions_line
+ ". Do not infer product measurements. Do not draw, label, or show an arrow for: "
+ ", ".join(missing_labels or ["any additional direction"])
+ ", depth, thickness, diameter, or any other unlisted dimension."
if known_dimensions else ""
)
params_line = "; ".join(
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
)
@@ -141,14 +164,18 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
"desc": desc,
"selling_points": selling_points[:3],
"params_line": params_line,
"params_line": "; ".join(filter(None, (params_line, dimensions_line))),
# 俄文图片里不能直接渲染中文参数;保留不含汉字的规格(型号、数字、单位)供尺寸图使用。
"params_line_non_cjk": "; ".join(
"params_line_non_cjk": "; ".join(filter(None, (
"; ".join(
f"{p.get('key')}: {p.get('value')}"
for p in (raw.get("params") or [])[:8]
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
),
dimensions_line,
))),
"price": raw.get("price") or "",
"dimension_annotation_instruction": dimension_annotation_instruction,
}
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
+15 -3
View File
@@ -98,12 +98,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
"Create a size chart: the unchanged product in clean front and side views on a light background, "
"thin measurement annotation lines (arrows) marking length, width and height with values beside "
"thin measurement annotation lines (arrows) only for supplied dimensions with values beside "
"each line"
+ (f" (known specs: {dims})" if dims else "")
+ ", small caption row, precise technical-drawing aesthetic."
+ f". {annotation} Small caption row, precise technical-drawing aesthetic."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
@@ -128,8 +129,19 @@ _TASK_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的 prompt:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。"""
if requirements_exclusive:
return "\n\n".join([
requirements_block(requirements),
"Edit the first image only as required above. Do not add marketing layouts, captions, overlays, "
"props, default studio styling, or visible text unless explicitly requested above.",
"PROTECTION: preserve the first image exactly unless the strict requirements explicitly request "
"a specific change. When they do, make only that requested change and preserve everything else.",
"Final check: follow the strict requirements over every default behavior; do not make changes "
"that were not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
+18 -4
View File
@@ -129,12 +129,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
"background; overlay thin technical annotation lines (arrows) marking length, width and height "
"with measurement values rendered beside each line"
"background; overlay thin technical annotation lines (arrows) only for the supplied dimensions, "
"with each measurement value rendered beside its line"
+ (f" (known specs: {dims})" if dims else "")
+ "; precise technical-drawing aesthetic, small caption row."
+ f". {annotation}; precise technical-drawing aesthetic, small caption row."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
@@ -160,8 +161,21 @@ _TASK_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的 edits 语义 prompt:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。"""
if requirements_exclusive:
# 单张 AI 编辑:用户要求是唯一的画面意图,绝不能被套图的营销图/棚拍/俄文文案覆盖。
return "\n\n".join([
requirements_block(requirements),
"TASK: Edit Image 1 only according to the STRICT REQUIREMENTS above. Do not add marketing "
"layouts, captions, overlays, props, default studio styling, or any visible text unless the "
"STRICT REQUIREMENTS explicitly ask for them.",
"PROTECTION: preserve Image 1 exactly unless the STRICT REQUIREMENTS explicitly request a "
"specific change. When they do, make only that requested change and preserve everything else.",
"FINAL CHECK: follow the STRICT REQUIREMENTS over every default behavior. Preserve the product "
"and do not make any change that is not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
+68
View File
@@ -0,0 +1,68 @@
"""单张 AI 编辑提示词:一次图像模型调用完成编辑、翻译和排版。"""
from __future__ import annotations
from services.prompts.common import requirements_block, resolve_style
LANGUAGES = {"ru": "Russian", "ar": "Arabic", "en": "English"}
BACKGROUNDS = {
"auto": "Keep the original background unless the user's requirements ask to change it.",
"white": "Replace only the background with clean pure white; retain natural contact shadows.",
"solid": "Replace only the background with a clean solid color as described below.",
"scene": "Replace only the background with the described realistic scene. Keep the product as the hero.",
"reference": "Use Image 2 for background and lighting direction. Do not copy its product.",
}
def build_single_edit_prompt(
*, requirements: str, config: dict, context: dict, style_set: int,
style_prompt: str | None, ratio: str, is_size_chart: bool, reference_count: int,
) -> str:
"""Image 1 always supplies the product; following images are supporting references only."""
language = LANGUAGES.get(config.get("target_language"), "Russian")
background = config.get("background") or "auto"
sections = [
requirements_block(requirements),
"Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. "
"Preserve the product's shape, proportions, color, material, logo and distinguishing details. "
"Do not replace it with products from any other image.",
]
if reference_count:
sections.append(
f"Images 2 through {reference_count + 1} are auxiliary references only. "
"Use them for product detail, material, scene or lighting as appropriate; "
"Image 1 remains the source of truth for the product."
)
sections.append("BACKGROUND: " + BACKGROUNDS.get(background, BACKGROUNDS["auto"]))
if config.get("background_description"):
sections.append("Background details: " + str(config["background_description"]).strip())
if config.get("translate_text"):
sections.append(
f"In this same image-generation pass, read the visible marketing text in Image 1, "
f"translate its meaning into {language}, replace the source-language marketing copy, "
"and redesign the text layout for a polished, legible composition. "
"For Arabic, use correct right-to-left typesetting. Keep factual values and units exact. "
"Preserve text printed on the physical product and its branding unless the user explicitly asks to edit them. "
"Do not add unsupported claims."
)
else:
sections.append(
"Do not invent marketing copy or translate existing image text. "
"Preserve text printed on the physical product unless explicitly requested otherwise."
)
if is_size_chart:
annotation = context.get("dimension_annotation_instruction")
if annotation:
sections.append(
"Create a clean product measurement diagram with legible dimension arrows. "
+ str(annotation)
)
style = resolve_style(style_set, style_prompt)
sections.append("VISUAL STYLE: " + str(style["tone"]))
sections.append(
f"OUTPUT: exactly {ratio} portrait or square composition as specified. "
"Apply the user's explicit background and text instructions before style preferences. "
"Do not add a watermark or extra panels."
)
return "\n\n".join(part for part in sections if part)
+2 -2
View File
@@ -76,7 +76,7 @@ async def append_generated_asset(
sort_order: int | None = None
if after_asset_id:
try:
anchor = await db.get(ProductAsset, UUID(after_asset_id))
anchor = await db.get(ProductAsset, uuid.UUID(after_asset_id))
except ValueError:
anchor = None
if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated":
@@ -89,7 +89,7 @@ async def append_generated_asset(
ProductAsset.sort_order > anchor.sort_order,
)
)
).scalars().all()
).all()
for follower in followers:
follower.sort_order += 1
sort_order = anchor.sort_order + 1
+10
View File
@@ -58,11 +58,16 @@ async def _run_process(
should_cancel: Callable[[], Awaitable[bool]],
timeout_seconds: int,
) -> None:
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频合成工具「{args[0]}」。请安装 FFmpeg,并配置 VIDEO_FFMPEG_PATH 为其可执行文件的绝对路径。"
) from exc
try:
waited = 0.0
while process.returncode is None:
@@ -91,6 +96,7 @@ async def _run_process(
async def _probe(path: Path) -> dict:
settings = get_settings()
try:
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
@@ -98,6 +104,10 @@ async def _probe(path: Path) -> dict:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频校验工具「{settings.video_ffprobe_path}」。请安装 FFmpeg,并配置 VIDEO_FFPROBE_PATH 为其可执行文件的绝对路径。"
) from exc
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
+71
View File
@@ -0,0 +1,71 @@
"""单图导出响应头回归测试。"""
from __future__ import annotations
import io
import sys
import unittest
import zipfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.export import ExportImageItem, ExportImagesRequest, export_image, export_images
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
async def test_extensionless_image_with_generic_mime_can_be_downloaded(self) -> None:
image = ExportImageItem(url="https://example.com/assets/opaque-key", key="upload")
png = b"\x89PNG\r\n\x1a\n" + b"image bytes"
with patch("api.export._download", new=AsyncMock(return_value=(png, "application/octet-stream"))):
response = await export_image(image)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.media_type, "image/png")
self.assertIn('filename="upload.png"', response.headers["content-disposition"])
async def test_extensionless_non_image_is_rejected(self) -> None:
image = ExportImageItem(url="https://example.com/assets/opaque-key")
with patch("api.export._download", new=AsyncMock(return_value=(b"not an image", "application/octet-stream"))):
with self.assertRaises(HTTPException) as raised:
await export_image(image)
self.assertEqual(raised.exception.status_code, 400)
async def test_chinese_filename_uses_rfc5987_header(self) -> None:
image = ExportImageItem(
url="https://example.com/image.png",
key="尺寸标注图",
variantName="蓝色",
)
with patch("api.export._download", new=AsyncMock(return_value=(b"image", "image/png"))):
response = await export_image(image)
disposition = response.headers["content-disposition"]
self.assertIn('filename="_____-__.png"', disposition)
self.assertIn("filename*=UTF-8''%E5%B0%BA%E5%AF%B8%E6%A0%87%E6%B3%A8%E5%9B%BE-%E8%93%9D%E8%89%B2.png", disposition)
async def test_flat_export_places_generated_images_in_product_root(self) -> None:
request = ExportImagesRequest(
title="商品",
flat=True,
images=[
ExportImageItem(url="https://example.com/single.png", groupName="单张生图", key="single"),
ExportImageItem(url="https://example.com/suite.png", groupName="套图结果", key="suite"),
],
)
with patch("api.export._download", new=AsyncMock(return_value=(b"image", "image/png"))):
response = await export_images(request)
payload = b"".join([chunk async for chunk in response.body_iterator])
self.assertEqual(
sorted(zipfile.ZipFile(io.BytesIO(payload)).namelist()),
["商品/single.png", "商品/suite.png"],
)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
"""平台目标比例到供应商尺寸参数的映射。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import _image_size
class ImageSizeTests(unittest.TestCase):
def test_pcore_ozon_size_is_exactly_three_to_four(self) -> None:
width, height = map(int, _image_size("pcore", "3:4").split("x"))
self.assertEqual(width * 4, height * 3)
if __name__ == "__main__":
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
"""后台生图 Worker 的取消竞态回归测试。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import GenerationAdvance
from services.job_worker import process_item
from services.jobs import ClaimedItem
class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase):
async def test_cancelled_task_is_not_returned_to_provider_waiting_queue(self) -> None:
claim = ClaimedItem(
job_id="00000000-0000-0000-0000-000000000001",
item_id="00000000-0000-0000-0000-000000000002",
job_kind="image_edit",
product_id=None,
provider="pcore",
model="gpt-image-2",
platform="ozon",
lang="ru",
ratio="3:4",
style_set=5,
request_payload={"context": {"title": "test"}, "ref_images": []},
item_payload={"kind": "custom", "title": "AI生图"},
provider_task_id="provider-task",
provider_task_kind="edits",
)
settings = SimpleNamespace(
image_provider="pcore",
dashscope_model="wan2.7-image-pro",
rightapi_image_model="gpt-image-2",
ark_image_model="doubao-seedream-4-5-251128",
)
with (
patch("services.job_worker.get_settings", return_value=settings),
patch("services.job_worker.build_context", return_value={}),
patch("services.job_worker.build_prompt", return_value="prompt"),
patch("services.job_worker._refs_for_job", return_value=[]),
patch("services.job_worker.advance_generation", new=AsyncMock(return_value=GenerationAdvance(
provider_task_id="provider-task", provider_task_kind="edits",
))),
patch("services.job_worker.job_can_write", new=AsyncMock(return_value=False)),
patch("services.job_worker.mark_item_cancelled", new=AsyncMock()) as cancelled,
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
):
await process_item(claim)
cancelled.assert_awaited_once_with(claim.item_id)
waiting.assert_not_awaited()
async def test_provider_poll_timeout_keeps_existing_task_for_retry(self) -> None:
claim = ClaimedItem(
job_id="00000000-0000-0000-0000-000000000001",
item_id="00000000-0000-0000-0000-000000000002",
job_kind="image_edit",
product_id=None,
provider="pcore",
model="gpt-image-2",
platform="ozon",
lang="ru",
ratio="3:4",
style_set=5,
request_payload={"context": {"title": "test"}, "ref_images": []},
item_payload={"kind": "custom", "title": "AI生图"},
provider_task_id="provider-task",
provider_task_kind="edits",
)
settings = SimpleNamespace(
image_provider="pcore",
dashscope_model="wan2.7-image-pro",
rightapi_image_model="gpt-image-2",
ark_image_model="doubao-seedream-4-5-251128",
)
with (
patch("services.job_worker.get_settings", return_value=settings),
patch("services.job_worker.build_context", return_value={}),
patch("services.job_worker.build_prompt", return_value="prompt"),
patch("services.job_worker._refs_for_job", return_value=[]),
patch("services.job_worker.advance_generation", new=AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))),
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
patch("services.job_worker.mark_item_failed", new=AsyncMock()) as failed,
):
await process_item(claim)
waiting.assert_awaited_once_with(
claim.item_id, "provider-task", "edits", delay_seconds=30,
)
failed.assert_not_awaited()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
"""仅扩图的像素保护与单张 AI 编辑提示词回归测试。"""
from __future__ import annotations
import asyncio
import base64
import io
import sys
import unittest
from pathlib import Path
from PIL import Image
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import prepare_outpaint_canvas
from services.prompts import build_prompt
def _data_url(image: Image.Image) -> str:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
class OutpaintTests(unittest.TestCase):
def test_outpaint_restores_original_region_after_provider_response(self) -> None:
original = Image.new("RGB", (400, 400), (220, 20, 60))
canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536"))
self.assertEqual(canvas.size, (1152, 1536))
self.assertEqual(canvas.box, (0, 192, 1152, 1344))
# 模拟供应商把整张图都重绘成蓝色;服务端合成后中心原图必须仍为红色。
generated = Image.new("RGB", canvas.size, (0, 0, 255))
restored = Image.open(io.BytesIO(canvas.restore_original(_png_bytes(generated)))).convert("RGB")
self.assertEqual(restored.getpixel((576, 768)), (220, 20, 60))
self.assertEqual(restored.getpixel((576, 80)), (0, 0, 255))
def test_expanded_margin_is_transparent_in_mask(self) -> None:
original = Image.new("RGB", (400, 400), (1, 2, 3))
canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536"))
mask_bytes = base64.b64decode(canvas.mask_data_url.split(",", 1)[1])
mask = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
self.assertEqual(mask.getpixel((576, 80))[3], 0)
self.assertEqual(mask.getpixel((576, 768))[3], 255)
def _png_bytes(image: Image.Image) -> bytes:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
class ExclusiveEditPromptTests(unittest.TestCase):
def test_user_edit_requirements_do_not_inherit_marketing_or_light_studio_prompt(self) -> None:
prompt = build_prompt(
"pcore", "gpt-image-2", "custom", {"title": "product"}, 5, "ru",
requirements="背景必须是纯黑色,其他不要改变",
requirements_exclusive=True,
)
self.assertIn("背景必须是纯黑色", prompt)
self.assertNotIn("e-commerce marketing image", prompt)
self.assertNotIn("plain light background", prompt)
self.assertNotIn("Render concise Russian marketing text", prompt)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""部分尺寸的尺寸标注图:仅传入已知方向,绝不补造缺失方向。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.suite import _with_dimensions
from schemas.suite import ProductDimensions
from services.prompts.common import build_context
from services.prompts.gpt import _task_size_chart
class PartialDimensionsTests(unittest.TestCase):
def test_only_known_direction_is_passed_to_prompt_context(self) -> None:
raw = _with_dimensions(
{"title": "测试商品", "params": [
{"key": "产品尺寸", "value": "12.5×8×4 cm"},
{"key": "材质", "value": "棉"},
]},
ProductDimensions(width=8, height=4, unit="cm"),
required=True,
)
self.assertEqual(raw["dimensions"], {"width": 8, "height": 4, "unit": "cm"})
self.assertEqual(raw["params"], [{"key": "材质", "value": "棉"}])
context = build_context(raw)
prompt = _task_size_chart(context, "en")
self.assertIn("width: 8.0 cm; height: 4.0 cm", prompt)
self.assertIn("render exactly 2 measurement arrows and labels, only for: width, height", prompt)
self.assertIn("Do not draw, label, or show an arrow for: length", prompt)
self.assertNotIn("12.5×8×4", prompt)
def test_size_chart_still_requires_at_least_one_dimension(self) -> None:
with self.assertRaisesRegex(Exception, "至少需要填写一项"):
_with_dimensions({"title": "测试商品"}, ProductDimensions(unit="mm"), required=True)
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
"""AI 套图规划的均衡覆盖回归测试。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.planner import _balanced_items
def item(kind: str, variant_name: str | None = None) -> dict:
return {
"kind": kind,
"title": kind,
"detail": "",
"prompt_hint": "",
"count": 1,
"variant_name": variant_name,
}
class BalancedPlanTests(unittest.TestCase):
def test_many_skus_do_not_crowd_out_core_image_types(self) -> None:
variants = ["黑色", "白色", "蓝色", "粉色", "绿色"]
plan = _balanced_items(
[item("white_bg", variant) for variant in variants],
variants,
{"params": [{"key": "尺寸", "value": "30 × 20 × 10 cm"}]},
None,
budget=15,
)
self.assertLessEqual(sum(p["count"] for p in plan if p["kind"] == "white_bg"), 2)
self.assertTrue(any(p["kind"] == "lifestyle" for p in plan))
self.assertTrue(any(p["kind"] == "material" for p in plan))
self.assertTrue(any(p["kind"] == "size_chart" for p in plan))
def test_does_not_add_size_chart_without_dimensions(self) -> None:
plan = _balanced_items(
[item("white_bg", "黑色")],
["黑色", "白色"],
{"params": [{"key": "规格", "value": "标准款"}, {"key": "材质", "value": "涤纶"}]},
None,
budget=15,
)
self.assertFalse(any(p["kind"] == "size_chart" for p in plan))
self.assertTrue(any(p["kind"] == "lifestyle" for p in plan))
self.assertTrue(any(p["kind"] == "material" for p in plan))
def test_explicit_every_sku_requirement_is_preserved(self) -> None:
variants = ["黑色", "白色", "蓝色"]
plan = _balanced_items(
[item("white_bg", variant) for variant in variants],
variants,
{},
"每个 SKU 都单独出一张白底图",
budget=15,
)
self.assertEqual(sum(p["count"] for p in plan if p["kind"] == "white_bg"), 3)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""单张编辑的新参数必须进入同一次生图任务,且主图顺序与尺寸准确。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.suite import suite_image_edit, _with_dimensions
from schemas.suite import ImageEditSingleRequest, ProductDimensions
from services.generator import _image_size
from services.prompts.common import build_context
from services.prompts.single_edit import build_single_edit_prompt
class SingleEditRequestTests(unittest.IsolatedAsyncioTestCase):
async def test_main_and_two_auxiliary_images_keep_order_in_one_item(self) -> None:
request = ImageEditSingleRequest(
main_image_url="main", reference_image_urls=["detail", "scene"],
prompt="替换背景", model="gpt-image-2@pcore", translate_text=True,
target_language="ar", background="reference", ratio="4:5",
)
job = AsyncMock(return_value=SimpleNamespace(id="task-id"))
with patch("api.suite.create_image_job", job):
response = await suite_image_edit(request)
self.assertEqual(response.suite_id, "task-id")
payload = job.await_args.kwargs
self.assertEqual(payload["ratio"], "4:5")
self.assertEqual(payload["lang"], "ar")
self.assertEqual(payload["provider"], "pcore")
self.assertEqual(payload["model"], "gpt-image-2")
self.assertEqual(len(payload["item_payloads"]), 1)
self.assertEqual([image["url"] for image in payload["request_payload"]["ref_images"]], ["main", "detail", "scene"])
self.assertTrue(payload["request_payload"]["single_edit"]["translate_text"])
async def test_structured_edit_rejects_other_provider(self) -> None:
with self.assertRaises(HTTPException) as raised:
await suite_image_edit(ImageEditSingleRequest(
main_image_url="main", model="nano-banana-2",
))
self.assertEqual(raised.exception.status_code, 400)
class SingleEditPromptTests(unittest.TestCase):
def test_translation_background_and_partial_dimensions_share_one_prompt(self) -> None:
context = build_context(_with_dimensions(
{"title": "AI 精修"}, ProductDimensions(width=8, unit="cm"), required=True,
))
prompt = build_single_edit_prompt(
requirements="保留商品颜色", config={"translate_text": True, "target_language": "ar", "background": "scene", "background_description": "warm kitchen"},
context=context, style_set=2, style_prompt=None, ratio="3:4", is_size_chart=True, reference_count=2,
)
self.assertIn("Image 1 is the fixed main product image", prompt)
self.assertIn("Images 2 through 3 are auxiliary", prompt)
self.assertIn("Arabic", prompt)
self.assertIn("right-to-left", prompt)
self.assertIn("warm kitchen", prompt)
self.assertIn("width: 8.0 cm", prompt)
self.assertIn("Do not draw, label, or show an arrow for: length, height", prompt)
def test_four_to_five_size_does_not_fall_back_to_square(self) -> None:
for provider, is_wan, model in (
("pcore", True, "gpt-image-2"),
("rightapi", True, "gpt-image-2"),
("doubao", True, "seedream"),
("tongyi", True, "wan2.6-image"),
("tongyi", False, "qwen-image-3.0-pro"),
):
with self.subTest(provider=provider, model=model):
width, height = map(int, _image_size(provider, "4:5", is_wan, model).replace("*", "x").split("x"))
self.assertEqual(width * 5, height * 4)
if __name__ == "__main__":
unittest.main()
+145 -36
View File
@@ -1,11 +1,123 @@
#!/bin/zsh
# 双击本文件,或在终端执行:./start.command
cd "$(dirname "$0")"
cd "$(dirname "$0")" || exit 1
PROJECT_DIR="$PWD"
BACKEND_HOST="127.0.0.1"
BACKEND_PORT="8800"
BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}"
STUDIO_URL="http://localhost:8900"
RUNTIME_DIR="$PROJECT_DIR/data/runtime"
INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid"
# 两个前端项目均锁定 pnpm 10.32.1。系统的 `pnpm` 是 Corepack shim,
# 其默认版本可能随 Node 更新而变化,因此显式指定项目所需的版本。
PNPM_VERSION="10.32.1"
project_pnpm() {
corepack "pnpm@${PNPM_VERSION}" "$@"
}
# 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束,
# 不扫描或误杀其他项目的 Python / Node 进程。
BACKEND_PID=""
STUDIO_PID=""
WORKER_SUPERVISOR_PID=""
WORKER_SOURCE_WATCH_PID=""
COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP=""
WORKER_SOURCE_STAMP=""
CLEANUP_DONE=false
stop_process_tree() {
local pid="$1"
local child
[[ "$pid" == <-> ]] || return 0
kill -0 "$pid" 2>/dev/null || return 0
# 先停止子进程,避免 Worker 守护循环在父进程退出前重新拉起 Worker。
for child in $(pgrep -P "$pid" 2>/dev/null); do
stop_process_tree "$child"
done
kill -TERM "$pid" 2>/dev/null || true
}
cleanup() {
[[ "$CLEANUP_DONE" == true ]] && return
CLEANUP_DONE=true
stop_process_tree "$BACKEND_PID"
stop_process_tree "$STUDIO_PID"
stop_process_tree "$WORKER_SUPERVISOR_PID"
stop_process_tree "$WORKER_SOURCE_WATCH_PID"
stop_process_tree "$COLLECTOR_WATCH_PID"
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null
[[ -n "$WORKER_SOURCE_STAMP" ]] && rm -f "$WORKER_SOURCE_STAMP" 2>/dev/null
if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then
rm -f "$INSTANCE_PID_FILE"
fi
}
on_signal() {
exit 0
}
stop_previous_instance() {
[[ -f "$INSTANCE_PID_FILE" ]] || return 0
local previous_pid command cwd
previous_pid="$(<"$INSTANCE_PID_FILE")"
if [[ "$previous_pid" != <-> ]] || ! kill -0 "$previous_pid" 2>/dev/null; then
rm -f "$INSTANCE_PID_FILE"
return 0
fi
command="$(ps -p "$previous_pid" -o command= 2>/dev/null)"
cwd="$(lsof -a -p "$previous_pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
if [[ "$command" != *"start.command"* || "$cwd" != "$PROJECT_DIR" ]]; then
echo "⚠️ 发现过期的启动记录,未匹配到本项目进程,已忽略。"
rm -f "$INSTANCE_PID_FILE"
return 0
fi
echo "检测到已有工具台实例(PID: $previous_pid),正在停止后重新启动…"
stop_process_tree "$previous_pid"
for _ in {1..50}; do
kill -0 "$previous_pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$previous_pid" 2>/dev/null; then
echo "⚠️ 旧实例未能停止;为避免启动重复 Worker,本次不启动新实例。"
exit 1
fi
rm -f "$INSTANCE_PID_FILE"
}
stop_unmanaged_project_workers() {
# PID 文件只能管理此前由 start.command 拉起的实例;这里额外收敛曾被手动
# 启动、但 cwd 明确属于当前项目的 Worker。绝不按 Python 名称做全局清理。
local pid parent_pid command cwd parent_command parent_cwd
for pid in $(pgrep -f 'server/worker\.py' 2>/dev/null); do
command="$(ps -p "$pid" -o command= 2>/dev/null)"
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
[[ "$command" == *"server/worker.py"* && "$cwd" == "$PROJECT_DIR" ]] || continue
parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ')"
parent_command="$(ps -p "$parent_pid" -o command= 2>/dev/null)"
parent_cwd="$(lsof -a -p "$parent_pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
echo "检测到遗留 Worker(PID: $pid),正在停止…"
if [[ "$parent_command" == *"start.command"* && "$parent_cwd" == "$PROJECT_DIR" ]]; then
# 这是脱离 PID 文件的旧守护循环;连同父进程停掉,避免它 2 秒后重拉 Worker。
stop_process_tree "$parent_pid"
else
# 手动启动的 Worker 只停止自身,不触碰其可能属于其他工具的父终端。
kill -TERM "$pid" 2>/dev/null || true
fi
done
}
mkdir -p "$RUNTIME_DIR" || exit 1
stop_previous_instance
stop_unmanaged_project_workers
print -r -- "$$" > "$INSTANCE_PID_FILE"
trap cleanup EXIT
trap on_signal INT TERM HUP
if [[ ! -d .venv ]]; then
echo "未找到 .venv,正在创建…"
@@ -32,7 +144,6 @@ fi
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)"
watch_collector() {
touch "$COLLECTOR_STAMP"
@@ -41,7 +152,7 @@ watch_collector() {
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
touch "$COLLECTOR_STAMP"
echo "🔧 检测到插件代码改动,重新构建…"
if pnpm -C extensions/collector build >/dev/null 2>&1; then
if project_pnpm -C extensions/collector build >/dev/null 2>&1; then
echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效"
else
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
@@ -52,13 +163,13 @@ watch_collector() {
if [[ ! -d extensions/collector ]]; then
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
elif ! command -v pnpm >/dev/null 2>&1; then
echo "⚠️ 未找到 pnpm,本次跳过采集插件构建(可手动:pnpm -C extensions/collector build)"
elif ! CI=true pnpm -C extensions/collector install --silent; then
elif ! command -v corepack >/dev/null 2>&1; then
echo "⚠️ 未找到 Corepack,本次跳过采集插件构建(可手动:corepack pnpm@${PNPM_VERSION} -C extensions/collector build)"
elif ! CI=true project_pnpm -C extensions/collector install --silent; then
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
else
echo "正在构建采集插件…"
if pnpm -C extensions/collector build; then
if project_pnpm -C extensions/collector build; then
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
else
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
@@ -68,29 +179,6 @@ else
fi
echo
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
STUDIO_PID=""
WORKER_SUPERVISOR_PID=""
cleanup() {
if [[ -n "$WORKER_SUPERVISOR_PID" ]] && kill -0 "$WORKER_SUPERVISOR_PID" 2>/dev/null; then
# 先终止 Worker 子进程,再结束守护循环;避免退出 start.command 后残留处理器。
pkill -P "$WORKER_SUPERVISOR_PID" 2>/dev/null
kill "$WORKER_SUPERVISOR_PID" 2>/dev/null
fi
if [[ -n "$STUDIO_PID" ]] && kill -0 "$STUDIO_PID" 2>/dev/null; then
kill "$STUDIO_PID" 2>/dev/null
sleep 0.2
# 兜底:确保 vite 及其子进程退出
lsof -ti :8900 | xargs kill 2>/dev/null
fi
if [[ -n "$COLLECTOR_WATCH_PID" ]] && kill -0 "$COLLECTOR_WATCH_PID" 2>/dev/null; then
pkill -P "$COLLECTOR_WATCH_PID" 2>/dev/null # 可能正在跑的 build 子进程
kill "$COLLECTOR_WATCH_PID" 2>/dev/null
fi
rm -f "$COLLECTOR_STAMP" 2>/dev/null
}
trap cleanup EXIT
# ---- 持久化后台任务 Worker(与 uvicorn 独立,服务重启不会中断正在轮询的模型任务)----
# Worker 因配置/网络等未捕获错误退出时,2 秒后自动拉起;否则任务会长期停在「排队中」。
supervise_worker() {
@@ -105,11 +193,29 @@ supervise_worker &
WORKER_SUPERVISOR_PID=$!
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
if ! command -v pnpm >/dev/null 2>&1; then
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
# Uvicorn 的热更新只管理 Web 进程;Worker 独立运行,代码更新后必须主动重启,
# 否则它会继续用内存中已导入的旧 generator.py 提交模型任务。
WORKER_SOURCE_STAMP="$(mktemp /tmp/osk-worker-source.XXXXXX)"
watch_worker_source() {
while true; do
sleep 2
if [[ -n "$(find server -type f \( -name '*.py' -o -name '*.yaml' \) -newer "$WORKER_SOURCE_STAMP" -print -quit 2>/dev/null)" ]]; then
touch "$WORKER_SOURCE_STAMP"
echo "🔧 检测到 Worker 源码改动,正在重启 Worker 以应用新版本…"
for worker_pid in $(pgrep -P "$WORKER_SUPERVISOR_PID" 2>/dev/null); do
kill -TERM "$worker_pid" 2>/dev/null || true
done
fi
done
}
watch_worker_source &
WORKER_SOURCE_WATCH_PID=$!
if ! command -v corepack >/dev/null 2>&1; then
echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)"
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
elif CI=true pnpm -C studio install --silent; then
pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器
elif CI=true project_pnpm -C studio install --silent; then
project_pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器
STUDIO_PID=$!
else
echo "⚠️ studio 依赖安装失败,本次只启动后端"
@@ -124,8 +230,11 @@ echo
echo "按 Ctrl+C 可停止全部服务"
echo
# --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host "$BACKEND_HOST" --port "$BACKEND_PORT"
# --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)。
# 后端改为后台子进程 + wait:start.command 本身始终存活,PID 文件和 EXIT 清理才能可靠生效。
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host "$BACKEND_HOST" --port "$BACKEND_PORT" &
BACKEND_PID=$!
wait "$BACKEND_PID"
echo
echo "服务已停止。按回车关闭窗口…"
+15 -2
View File
@@ -22,7 +22,6 @@ interface Props {
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
const [models, setModels] = useState<AiModelOption[]>([]);
const [model, setModel] = useState<string>('');
const [sourceText, setSourceText] = useState('');
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<CopyResponse | null>(null);
@@ -41,8 +40,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
return parts.join('\n\n');
}, [raw]);
// 文案模块的“采集信息”是可人工整理的 AI 输入。优先恢复用户上次保存的内容;
// 只有从未编辑过时才从当前采集数据自动拼出默认值。
const savedSourceText = typeof raw.copy_source_text === 'string' ? raw.copy_source_text : null;
const [sourceText, setSourceText] = useState(() => savedSourceText ?? defaultSource);
useEffect(() => {
if (!sourceText && defaultSource) setSourceText(defaultSource);
getAiModels()
.then((r) => {
setModels(r.models);
@@ -52,6 +55,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 与价格试算一致:用户完成输入(失焦)后将编辑后的 AI 输入落到当前商品。 */
const saveSourceText = async () => {
if (sourceText === savedSourceText) return;
await onSave({ raw: { ...raw, copy_source_text: sourceText } });
};
const onGenerate = async () => {
if (sourceText.trim().length < 10) {
message.warning('请先粘贴至少 10 字的商品资料');
@@ -115,8 +124,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
rows={16}
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
onBlur={() => { void saveSourceText(); }}
placeholder="采集的商品资料(可编辑后生成)"
/>
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 12 }}>
修改后失去焦点会自动保存
</Text>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Select
style={{ width: 180 }}
+17 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd';
import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
import { calculatePricing, legacyLineMultiplierToDiscountRate, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
import { getFxRate } from '@/services/fx';
import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
@@ -33,16 +33,18 @@ function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
}
/**
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线价倍数)。
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线折扣率)。
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 销售价 × (1 + 倍数%))。
* 任一定价参数变化即重算并回填售价与划线价(= 销售价 ÷ (1 - 划线折扣率))。
*/
export default function PriceInfoPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [discountRate, setDiscountRate] = useState(
pricing.lineDiscountRate ?? legacyLineMultiplierToDiscountRate(pricing.lineMultiplier ?? 100),
);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
@@ -67,7 +69,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
const tdPrice = pricing.tdPrice ?? 3;
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) =>
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; discountRate: number; fxRate: number }> = {}) =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
@@ -75,17 +77,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
lineDiscountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) => {
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; discountRate: number; fxRate: number }> = {}) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
discountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
};
const r = compute(patch);
@@ -98,7 +100,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
lineMultiplier: p.multiplier,
lineDiscountRate: p.discountRate,
fxRate: p.fxRate,
logisticsFee: r.logisticsFee,
fullCommission: r.fullCommission,
@@ -189,15 +191,16 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
/>
</Col>
<Col span={5}>
<FieldLabel>划线价倍数 %</FieldLabel>
<FieldLabel>划线折扣率 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
value={multiplier}
max={99.99}
precision={2}
value={discountRate}
onChange={(v) => {
setMultiplier(v ?? 0);
recalc({ multiplier: v ?? 0 });
setDiscountRate(v ?? 0);
recalc({ discountRate: v ?? 0 });
}}
/>
</Col>
@@ -0,0 +1,40 @@
.single-ai-drawer .ant-drawer-body { padding: 18px 22px; background: #f8f9fb; }
.single-ai-drawer .ant-drawer-footer { padding: 12px 22px; }
.single-ai-title { display: flex; gap: 12px; align-items: baseline; font-weight: 650; }
.single-ai-title .ant-typography { font-size: 12px; font-weight: normal; }
.single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(310px, .82fr); gap: 18px; align-items: start; }
.single-ai-media, .single-ai-settings { display: grid; gap: 14px; min-width: 0; }
.single-ai-section { background: #fff; border: 1px solid #e9ebef; border-radius: 12px; padding: 16px; min-width: 0; }
.single-ai-section-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
.single-ai-section-head strong { font-size: 14px; }
.single-ai-section-head .ant-typography { font-size: 12px; }
.single-ai-reference-grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: repeat(2, 1fr); aspect-ratio: 3 / 2; gap: 8px; min-height: 220px; }
.single-ai-reference-grid.outpaint { grid-template-columns: 1fr; }
.single-ai-main-image, .single-ai-aux-image { position: relative; min-width: 0; min-height: 0; overflow: hidden; border-radius: 9px; background: #f3f4f6; }
.single-ai-main-image { grid-row: span 2; }
.single-ai-main-image img, .single-ai-aux-image img { width: 100%; height: 100%; display: block; object-fit: contain; }
.single-ai-main-image > span, .single-ai-aux-image > span { position: absolute; left: 8px; bottom: 8px; border-radius: 5px; padding: 3px 7px; background: rgba(20, 24, 33, .7); color: #fff; font-size: 11px; }
.single-ai-aux-image > .ant-btn { position: absolute; top: 6px; right: 6px; }
.single-ai-aux-image > button:not(.ant-btn) { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #c9ccd3; background: #fafafa; cursor: pointer; color: #737984; }
.single-ai-aux-image > button:not(.ant-btn) span { position: static; background: none; color: inherit; }
.single-ai-aux-image > .single-ai-change-ref { position: absolute !important; right: 6px; bottom: 6px; width: auto !important; height: auto !important; padding: 3px 7px; border: 0 !important; border-radius: 5px; background: rgba(20, 24, 33, .7) !important; color: white !important; font-size: 11px; }
.single-ai-result-frame { width: min(100%, 300px); max-height: 440px; margin: 0 auto 10px; overflow: hidden; background: #f7f8fa; border: 1px dashed #d9dde4; border-radius: 9px; display: flex; justify-content: center; align-items: center; }
.single-ai-result-frame .ant-image { display: block; width: 100%; height: 100%; }
.single-ai-result-frame .ant-image img { display: block; width: 100%; height: 100%; object-fit: contain; }
.single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px; text-align: center; color: #808793; font-size: 12px; }
.single-ai-empty strong { color: #30343b; font-size: 14px; }
.single-ai-sparkle { color: #25ab65; font-size: 32px; }
.single-ai-settings .ant-typography-secondary { display: block; margin-top: 6px; font-size: 12px; }
.single-ai-style-grid { display: flex; flex-wrap: wrap; gap: 7px; }
.single-ai-style-grid button { border: 1px solid #dce0e6; background: #fff; border-radius: 18px; padding: 5px 12px; cursor: pointer; }
.single-ai-style-grid button.selected { border-color: #20a260; color: #16874f; background: #effaf3; }
.single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 74px; gap: 7px; margin-bottom: 8px; }
.single-ai-dimensions .ant-input-number { width: 100%; }
.single-ai-advanced { display: grid; gap: 8px; }
.single-ai-picker { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-height: 420px; overflow: auto; }
.single-ai-picker button { min-width: 0; padding: 4px; border: 1px solid #ddd; background: #fff; border-radius: 8px; cursor: pointer; }
.single-ai-picker img { display: block; width: 100%; aspect-ratio: 1; object-fit: contain; }
.single-ai-picker span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.single-ai-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.single-ai-footer > div { display: flex; gap: 8px; }
@media (max-width: 760px) { .single-ai-layout { grid-template-columns: 1fr; } .single-ai-footer > .ant-typography { display: none; } .single-ai-footer { justify-content: flex-end; } }
+158 -163
View File
@@ -1,40 +1,61 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { Alert, Button, Checkbox, Drawer, Image, Input, InputNumber, message, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, WatermarkPayload } from '@/services/suite';
import {
DEFAULT_IMAGE_MODEL, STYLE_SET_OPTIONS, cancelSuite, getSuite,
imageEditSingle, ProductDimensions, WatermarkPayload,
} from '@/services/suite';
import './AiImageGenModal.css';
const { Text } = Typography;
type ImageOption = { url: string; name: string };
type Ratio = '3:4' | '1:1' | '4:5';
type Language = 'ru' | 'ar' | 'en';
type Background = 'auto' | 'white' | 'solid' | 'scene' | 'reference';
interface Props {
open: boolean;
productId: string;
/** 待生成的源图(采集图或生成图) */
source: { url: string; name: string; afterAssetId?: string } | null;
source: (ImageOption & { afterAssetId?: string }) | null;
referenceOptions: ImageOption[];
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
onSubmitted?: (suiteId: string) => void;
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [auxiliary, setAuxiliary] = useState<(ImageOption | null)[]>([null, null]);
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
const [ratio, setRatio] = useState<Ratio>('3:4');
const [translateText, setTranslateText] = useState(false);
const [language, setLanguage] = useState<Language>('ru');
const [background, setBackground] = useState<Background>('auto');
const [backgroundDescription, setBackgroundDescription] = useState('');
const [styleSet, setStyleSet] = useState<number>(2);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [showStylePrompt, setShowStylePrompt] = useState(false);
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const activeTaskRef = useRef<string | null>(null);
const previousSourceRef = useRef<string | null>(null);
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
const stylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((item) => item.value === styleSet)?.prompt ?? '';
const stopPolling = () => {
pollRunRef.current += 1;
@@ -44,17 +65,25 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
pollAbortRef.current = null;
};
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
useEffect(() => {
if (open && !activeTaskRef.current) {
if (!open || !source || activeTaskRef.current || previousSourceRef.current === source.url) return;
previousSourceRef.current = source.url;
setAuxiliary([null, null]);
setPrompt('');
setMode('edit');
setRatio('3:4');
setTranslateText(false);
setLanguage('ru');
setBackground('auto');
setBackgroundDescription('');
setDimensions({ unit: 'cm' });
setSizeChartOverride(null);
setResultUrl('');
setGenerating(false);
setTaskId('');
setResultRatio('3:4');
setTaskError('');
}
}, [open]);
setTaskId('');
}, [open, source?.url]);
useEffect(() => { if (!open) previousSourceRef.current = null; }, [open]);
useEffect(() => () => stopPolling(), []);
const pollTask = (suiteId: string) => {
@@ -63,12 +92,8 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
const controller = new AbortController();
pollAbortRef.current = controller;
activeTaskRef.current = suiteId;
const startedAt = Date.now();
let failures = 0;
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const schedule = (delay: number) => { pollTimerRef.current = setTimeout(() => void poll(), delay); };
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
@@ -90,34 +115,24 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
if (result) {
setResultUrl(result.url);
setTaskError(suite.error || '');
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(result.url);
} else if (suite.status === 'cancelled') {
setTaskError('任务已取消');
message.info('任务已取消');
message.success('单张图片生成完成');
} else {
const detail = suite.error || '生成失败,未返回图片';
const detail = suite.status === 'cancelled' ? '任务已取消' : suite.error || '生成失败,未返回图片';
setTaskError(detail);
message.error(detail);
if (suite.status !== 'cancelled') message.error(detail);
}
return;
}
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
if (Date.now() - startedAt > 6 * 60_000) {
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
}
schedule(3000);
} catch (e) {
} catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
failures += 1;
if (failures >= 3) {
finish();
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。';
setTaskError(detail);
message.error(detail);
setTaskError('查询进度失败,任务仍可能在后台运行;可在生成结果区域查看。');
return;
}
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
schedule(Math.min(3000 * (2 ** failures), 12_000));
}
};
@@ -125,33 +140,37 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
};
const onGenerate = async () => {
if (!source) return;
if (!source?.url || generating) return;
if (mode === 'edit' && background === 'reference' && !auxiliary.some(Boolean)) {
message.warning('请选择一张辅助参考图作为背景参考');
return;
}
setGenerating(true);
setResultUrl('');
setResultRatio(mode === 'outpaint' ? '3:4' : ratio);
setTaskError('');
try {
const r = await imageEditSingle({
const response = await imageEditSingle({
product_id: productId,
image_url: source.url,
prompt: prompt.trim(),
model,
append: true,
after_asset_id: source.afterAssetId,
main_image_url: source.url,
reference_image_urls: mode === 'outpaint' ? [] : auxiliary.flatMap((item) => item ? [item.url] : []),
prompt: prompt.trim(), mode, model: DEFAULT_IMAGE_MODEL,
ratio: mode === 'outpaint' ? '3:4' : ratio, translate_text: mode === 'edit' && translateText,
target_language: language, style_set: styleSet, style_prompt: stylePrompt,
background, background_description: backgroundDescription.trim() || null,
generate_size_chart: mode === 'edit' && generateSizeChart,
dimensions: hasDimensions ? dimensions : null,
append: true, after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined,
});
setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
if (onSubmitted) {
onSubmitted(r.suite_id);
onClose();
} else {
// 独立使用该组件时仍保留自身轮询能力。
pollTask(r.suite_id);
}
} catch (e) {
message.error(apiErrorMessage(e));
setTaskError(apiErrorMessage(e));
setTaskId(response.suite_id);
onSubmitted?.(response.suite_id);
pollTask(response.suite_id);
} catch (error) {
const detail = apiErrorMessage(error);
setTaskError(detail);
setGenerating(false);
message.error(detail);
}
};
@@ -163,113 +182,89 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
setTaskError('任务已取消;已完成的图片会保留在生成结果中。');
} catch (e) {
message.error(apiErrorMessage(e));
}
setTaskError('任务已取消');
} catch (error) { message.error(apiErrorMessage(error)); }
};
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', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
const choices = Array.from(new Map(referenceOptions.filter((item) => item.url && item.url !== source?.url)
.map((item) => [item.url, item])).values());
const chooseReference = (item: ImageOption) => {
if (choosingSlot === null) return;
setAuxiliary((previous) => previous.map((selected, index) =>
index === choosingSlot ? item : selected?.url === item.url ? null : selected));
setChoosingSlot(null);
};
return <>
<Drawer className="single-ai-drawer" title={<div className="single-ai-title">单张 AI 编辑 <Text type="secondary">每次生成 1 张</Text></div>}
placement="right" width="min(1040px, 100vw)" open={open} onClose={onClose} destroyOnHidden={false}
footer={<div className="single-ai-footer"><Text type="secondary">生成结果会同时保存到「生成结果」</Text><div>
{generating && taskId && <Button danger onClick={() => void onCancelTask()}>取消任务</Button>}
<Button onClick={onClose}>关闭</Button>
<Button type="primary" loading={generating} disabled={!source} onClick={() => void onGenerate()}>{resultUrl ? '重新生成 1 张' : '生成 1 张'}</Button>
</div></div>}>
<div className="single-ai-layout">
<div className="single-ai-media">
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>主图与辅助参考</strong><Text type="secondary">{mode === 'outpaint' ? '仅使用主图' : '主图固定 · 辅图最多 2 张'}</Text></div>
<div className={`single-ai-reference-grid${mode === 'outpaint' ? ' outpaint' : ''}`}>
<div className="single-ai-main-image"><img src={source?.url} alt={source?.name || '主图'} /><span>主图 · 编辑底图</span></div>
{mode === 'edit' && auxiliary.map((item, index) => <div className="single-ai-aux-image" key={index}>
{item ? <><img src={item.url} alt={item.name} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setAuxiliary((previous) => previous.map((value, i) => i === index ? null : value))} /></>
: <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /><span>添加辅图 {index + 1}</span></button>}
{item && <button className="single-ai-change-ref" type="button" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button>}
</div>)}
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Image
src={source?.url}
alt="原图"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/>
</section>
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>AI 生成结果</strong><Text type="secondary">按 {resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio} 展示</Text></div>
<div className="single-ai-result-frame" style={{ aspectRatio: (resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio).replace(':', ' / ') }}>
{resultUrl ? <Image src={resultUrl} alt="AI 生成结果" /> : generating ? <div className="single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会直接展示在这里</span></div>
: <div className="single-ai-empty"><span className="single-ai-sparkle">✦</span><strong>生成的图片会展示在这里</strong><span>主图是编辑底图,生成结果单独显示</span></div>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Input.TextArea
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
disabled={generating}
/>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 0 }}
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}
{resultUrl && <a href={resultUrl} target="_blank" rel="noreferrer">打开原图</a>}
{taskError && <Alert style={{ marginTop: 10 }} type="error" showIcon message={taskError} />}
</section>
</div>
<div className="single-ai-settings">
<section className="single-ai-section"><div className="single-ai-section-head"><strong>编辑要求</strong></div>
<Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder={mode === 'outpaint' ? '描述新增边缘的画面;原图区域会保持不变' : '描述要改什么,例如:保留商品外观,换成温暖的客厅背景,突出材质质感'} />
</section>
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
{translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} />
<Text type="secondary">生成时识别并翻译图片文案,重新排版;不会额外发起识图请求。</Text></>}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>背景</strong></div>
<Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[
{ value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' },
{ value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' },
]} />
{['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>视觉风格</strong></div>
<div className="single-ai-style-grid">{STYLE_SET_OPTIONS.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
<Button type="link" size="small" style={{ paddingLeft: 0, marginTop: 6 }} onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
{showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((previous) => ({ ...previous, [styleSet]: event.target.value }))} />}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
<div className="single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((previous) => ({ ...previous, [key]: typeof value === 'number' ? value : undefined }))} />)}
<Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((previous) => ({ ...previous, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
{hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
<Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向。</Text>
</section>}
<section className="single-ai-section"><div className="single-ai-section-head"><strong>画面比例</strong></div>
<Radio.Group optionType="button" buttonStyle="solid" value={mode === 'outpaint' ? '3:4' : ratio} disabled={generating || mode === 'outpaint'} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
<Button type="link" size="small" style={{ display: 'block', paddingLeft: 0, marginTop: 8 }} onClick={() => setAdvancedOpen(!advancedOpen)}>{advancedOpen ? '收起生成方式' : '生成方式'}</Button>
{advancedOpen && <div className="single-ai-advanced">
<Select value={mode} disabled={generating} onChange={(value: 'edit' | 'outpaint') => setMode(value)} options={[{ value: 'edit', label: 'AI 编辑' }, { value: 'outpaint', label: '仅扩图至 3:4(保留原图)' }]} />
<Text type="secondary">使用 Pcore 中转 · gpt-image-2{mode === 'outpaint' ? ';原图区域会覆盖回生成结果。' : ''}</Text>
</div>}
</section>
</div>
</div>
)}
/>
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
提交任务
</Button>
</div>
</div>
<Alert
style={{ marginTop: 16 }}
type={taskError && !generating ? 'error' : 'info'}
showIcon
message={
generating
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
}
/>
{generating && taskId && (
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
取消此任务
</Button>
)}
</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>
)}
</>
);
</Drawer>
<Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} footer={null} onCancel={() => setChoosingSlot(null)}>
<div className="single-ai-picker">{choices.length ? choices.map((item) => <button type="button" key={item.url} onClick={() => chooseReference(item)}><img src={item.url} alt={item.name} /><span>{item.name}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
</Modal>
</>;
}
+2 -2
View File
@@ -29,7 +29,7 @@ interface RegisterRecord {
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
sellingPriceRub: string;
sellingPriceRubReserved: string; // 划线价 ₽
lineMultiplier: number; // 划线价倍数 %(v1 discountReserve 位)
lineDiscountRate: number; // 划线折扣率 %
exchangeRate: string;
logisticsFee: string;
receivedPrice: string;
@@ -95,7 +95,7 @@ function toRecord(product: ProductDetail): RegisterRecord | null {
sellingPriceReserved: f(pr.linePriceCny),
sellingPriceRub: f(pr.sellingPriceRub),
sellingPriceRubReserved: f(pr.linePriceRub),
lineMultiplier: pr.lineMultiplier ?? 100,
lineDiscountRate: pr.lineDiscountRate ?? 50,
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
logisticsFee: f(pr.logisticsFee),
receivedPrice: f(pr.receivedPrice),
@@ -164,6 +164,11 @@ interface GeneratedImageGridProps {
export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) {
const [page, setPage] = useState(1);
const { current, visible } = useVisiblePage(images, page);
// 新完成的图片经过时间倒序会进入首位;无论用户此前停在哪一页,都直接展示最新结果。
const newestKey = images[0]?.key;
useEffect(() => {
setPage(1);
}, [newestKey]);
useEffect(() => {
if (current !== page) setPage(current);
}, [current, page]);
+56 -41
View File
@@ -7,6 +7,7 @@ import { apiErrorMessage } from '@/services/api';
import { copyText } from '@/utils/file';
import {
calculatePricing,
legacyLineMultiplierToDiscountRate,
logisticsFeeRule,
validateDimensions,
validateLogisticsLevelCny,
@@ -30,7 +31,7 @@ interface Props {
type PricingPayload = Record<string, any>;
type Dims = { l: number | null; w: number | null; h: number | null };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number; weightG: number; dims: Dims }>;
type PricingState = Partial<{ purchasePrice: number | null; profitRate: number; tdPrice: number; level: LogisticsLevel; discountRate: number; fxRate: number; weightG: number | null; dims: Dims }>;
function dimsCm(product: ProductDetail): Dims {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
@@ -60,7 +61,7 @@ function SalePriceGrid({
cnyLine,
rubSelling,
rubLine,
multiplier,
discountRate,
fxRate,
cnyGap,
rubGap,
@@ -70,7 +71,7 @@ function SalePriceGrid({
cnyLine: string;
rubSelling: string;
rubLine: string;
multiplier: number;
discountRate: number;
fxRate: number;
cnyGap: number | null;
rubGap: number | null;
@@ -88,7 +89,7 @@ function SalePriceGrid({
lineHeight: '30px',
};
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' };
const multiple = (1 + multiplier / 100).toFixed(2);
const discountText = `${discountRate.toFixed(2).replace(/\.00$/, '')}%`;
const copyCnySelling = async () => {
if (!cnySellingNumber) return;
@@ -133,7 +134,7 @@ function SalePriceGrid({
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
<div style={noteStyle}>
销售价 × {multiple}
划线折扣 {discountText}
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
</div>
</div>
@@ -141,7 +142,7 @@ function SalePriceGrid({
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
<div style={noteStyle}>
销售价 × {multiple}
划线折扣 {discountText}
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
</div>
</div>
@@ -153,18 +154,20 @@ function SalePriceGrid({
/**
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
* 划线价为「倍数」口径:划线价 = 销售价 × (1 + 倍数%),0-500% 线性可算(v2.1 调整)。
* 划线价按折扣率计算:划线价 = 销售价 ÷ (1 - 划线折扣率)。
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
* 尺寸不合规时不落计价结果。
*/
export default function TrialPricingPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [purchasePrice, setPurchasePrice] = useState<number | null>(pricing.purchasePrice ?? null);
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 [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [discountRate, setDiscountRate] = useState(
pricing.lineDiscountRate ?? legacyLineMultiplierToDiscountRate(pricing.lineMultiplier ?? 100),
);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
const [fxSource, setFxSource] = useState('');
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
@@ -206,39 +209,43 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const compute = (patch: PricingState = {}): PricingResult =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
purchasePrice: patch.purchasePrice ?? purchasePrice ?? 0,
profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level,
weightG: wG(patch.weightG ?? weight),
dims: dimsNum(patch.dims ?? dims),
tdPrice: patch.tdPrice ?? tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
lineDiscountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
purchasePrice: patch.purchasePrice !== undefined ? patch.purchasePrice : purchasePrice,
profitRate: patch.profitRate ?? profitRate,
tdPrice: patch.tdPrice ?? tdPrice,
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
discountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
weightG: wG(patch.weightG ?? weight),
dimsNow: dimsNum(patch.dims ?? dims),
weightG: patch.weightG !== undefined ? patch.weightG : weight,
dimsNow: patch.dims ?? dims,
};
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level);
const r = dimError ? null : calculatePricing({
purchasePrice: p.purchasePrice,
const complete = p.purchasePrice != null && p.weightG != null
&& [p.dimsNow.l, p.dimsNow.w, p.dimsNow.h].every((value) => value != null)
&& p.fxRate > 0;
const dimsForCalculation = dimsNum(p.dimsNow);
const dimError = complete ? validateDimensions(p.weightG!, dimsForCalculation, p.level) : '';
const r = complete && !dimError ? calculatePricing({
purchasePrice: p.purchasePrice!,
profitRate: p.profitRate,
logisticsLevel: p.level,
weightG: p.weightG,
dims: p.dimsNow,
weightG: p.weightG!,
dims: dimsForCalculation,
tdPrice: p.tdPrice,
lineMultiplier: p.multiplier,
lineDiscountRate: p.discountRate,
fxRate: p.fxRate,
});
}) : null;
onSave({
...extraSave,
pricing: {
@@ -247,7 +254,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
profitRate: p.profitRate,
tdPrice: p.tdPrice,
logisticsLevel: p.level,
lineMultiplier: p.multiplier,
lineDiscountRate: p.discountRate,
fxRate: p.fxRate,
weightG: p.weightG,
dims: p.dimsNow,
@@ -276,7 +283,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
const onWeightChange = (v: number | null) => {
setWeight(v);
recalc({ weightG: v ?? 0 }, { weight: v, weight_unit: 'g' });
recalc({ weightG: v }, { weight: v, weight_unit: 'g' });
};
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
@@ -292,8 +299,11 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const curWeightG = wG(weight);
const curDims = dimsNum(dims);
const dimError = fxRate ? validateDimensions(curWeightG, curDims, level) : '';
const preview = fxRate && !dimError ? compute() : null;
const hasCompleteInputs = purchasePrice != null && weight != null
&& [dims.l, dims.w, dims.h].every((value) => value != null)
&& fxRate > 0;
const dimError = hasCompleteInputs ? validateDimensions(curWeightG, curDims, level) : '';
const preview = hasCompleteInputs && !dimError ? compute() : null;
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
@@ -310,6 +320,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={weight}
onChange={(v) => onWeightChange(v)}
/>
@@ -319,6 +330,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={dims.l}
onChange={(v) => onDimChange('l', v)}
/>
@@ -328,6 +340,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={dims.w}
onChange={(v) => onDimChange('w', v)}
/>
@@ -337,6 +350,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={dims.h}
onChange={(v) => onDimChange('h', v)}
/>
@@ -344,15 +358,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel>进货价 ¥</FieldLabel>
<FieldLabel>净货价 ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={purchasePrice}
onChange={(v) => {
const n = v ?? 0;
setPurchasePrice(n);
recalc({ purchasePrice: n });
setPurchasePrice(v);
recalc({ purchasePrice: v });
}}
/>
</Col>
@@ -385,17 +399,18 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/>
</Col>
<Col span={12}>
<FieldLabel>划线价倍数 %</FieldLabel>
<FieldLabel>划线折扣率 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
step={5}
value={multiplier}
max={99.99}
precision={2}
step={1}
value={discountRate}
onChange={(v) => {
const n = v ?? 0;
setMultiplier(n);
recalc({ multiplier: n });
setDiscountRate(n);
recalc({ discountRate: n });
}}
/>
</Col>
@@ -450,7 +465,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
划线价 = 销售价 × (1 + 倍数%);参数与重量/尺寸变化自动计价并入库;尺寸不合规时不落计价结果。
划线价 = 销售价 ÷ (1 − 划线折扣率);折扣率需 ≥ 0 且 &lt; 100%。填写净货价、重量和长宽高后自动计价。
</Text>
{(levelHint || rangeHint) && (
@@ -496,15 +511,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<Metric
label="完全成本"
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
rule="(进货价 + 物流费 + 平台总抽成)"
rule="(净货价 + 物流费 + 平台总抽成)"
color={COLOR_RED}
/>
</Col>
<Col span={8}>
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" />
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(净货价 × 净利率)" />
</Col>
<Col span={8}>
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" />
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(净货价 × (1 + 净利率))" />
</Col>
</Row>
@@ -515,7 +530,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'}
multiplier={multiplier}
discountRate={discountRate}
fxRate={fxRate}
cnyGap={preview ? discountGap : null}
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : null}
+202 -77
View File
@@ -3,9 +3,9 @@
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import {
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress,
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, InputNumber, Popconfirm, Popover, Progress,
Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
@@ -15,7 +15,7 @@ import {
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset,
type PlanItem,
type PlanItem, type ProductDimensions,
} from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal';
@@ -28,6 +28,9 @@ const { Text } = Typography;
const WATERMARK_STORAGE_KEY = 'trialWatermark';
const hasAnyDimensions = (dimensions: ProductDimensions) =>
[dimensions.length, dimensions.width, dimensions.height].some((value) => typeof value === 'number' && value > 0);
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const [text, setText] = useState(String(value));
@@ -158,12 +161,15 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState('');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
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 [activeSuiteTasks, setActiveSuiteTasks] = useState<SuiteInfo[]>([]);
const [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
/** 生图结果选择:套图当次结果与已落库的单张/套图历史共用一套选择状态。 */
@@ -173,9 +179,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
const [applyingWatermark, setApplyingWatermark] = useState(false);
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const suitePollersRef = useRef(new Map<string, { timer: ReturnType<typeof setTimeout> | null; controller: AbortController }>());
const activeSuiteIdsRef = useRef(new Set<string>());
/** 当前展示的任务;其他任务继续轮询,但不能把当前结果面板覆盖掉。 */
const focusedSuiteIdRef = useRef<string | null>(null);
const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const watermarkPollAbortRef = useRef<AbortController | null>(null);
const watermarkPollRunRef = useRef(0);
@@ -208,15 +215,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const suiteImages = (suite?.images ?? [])
.filter((img) => img.status === 'done')
.map((img, index) => {
const asset = generatedAssets.find((a) => assetUrl(a) === img.url);
const asset = generatedAssets.find((a) => a.id === img.asset_id)
|| generatedAssets.find((a) => assetUrl(a) === img.url);
return {
key: `suite-${suite?.id ?? 'current'}-${index}`,
// 素材已回写时与历史记录共用同一个 key,避免同一张图显示两次。
key: asset ? `asset-${asset.id}` : `suite-${suite?.id ?? 'current'}-${img.item_id || index}`,
url: img.url,
name: img.name,
groupName: '套图结果',
groupName: '生成结果',
variantName: null as string | null,
assetId: asset?.id,
source: 'suite' as const,
source: asset ? 'history' as const : 'suite' as const,
createdAt: asset?.created_at || img.completed_at || suite?.completed_at || suite?.created_at || undefined,
};
});
const historyImages = generatedAssets.map((asset) => ({
@@ -229,10 +239,22 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
source: 'history' as const,
createdAt: asset.created_at,
}));
return [...suiteImages, ...historyImages].filter((img) => !hiddenResultKeys.has(img.key));
const unique = new Map<string, ResultImage>();
// 已落库的素材带有最终 created_at,后写入可覆盖同 key 的临时 suite 结果。
[...suiteImages, ...historyImages].forEach((image) => unique.set(image.key, image));
return [...unique.values()]
.filter((img) => !hiddenResultKeys.has(img.key))
.sort((left, right) => {
const timestamp = (value?: string) => {
const parsed = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(parsed) ? parsed : 0;
};
const rightTime = timestamp(right.createdAt);
const leftTime = timestamp(left.createdAt);
return rightTime - leftTime || right.key.localeCompare(left.key);
});
}, [generatedAssets, hiddenResultKeys, suite]);
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
const historyResultImages = resultImages.filter((img) => img.source === 'history');
const otherActiveSuiteTasks = activeSuiteTasks.filter((task) => task.id !== suite?.id);
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => img.assetId!)));
@@ -259,12 +281,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets]);
const stopSuitePolling = (suiteId: string) => {
const poller = suitePollersRef.current.get(suiteId);
if (!poller) return;
if (poller.timer) clearTimeout(poller.timer);
poller.controller.abort();
suitePollersRef.current.delete(suiteId);
};
const stopPolling = () => {
pollRunRef.current += 1;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current?.abort();
pollAbortRef.current = null;
Array.from(suitePollersRef.current.keys()).forEach(stopSuitePolling);
};
const toggleKey = (key: string) => {
@@ -340,39 +366,59 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
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 runId = ++pollRunRef.current;
const pollSuite = (suiteId: string, focus = true) => {
// 只替换同一任务的轮询,绝不停止其他正在运行的套图任务。
stopSuitePolling(suiteId);
const controller = new AbortController();
pollAbortRef.current = controller;
const poller = { timer: null as ReturnType<typeof setTimeout> | null, controller };
suitePollersRef.current.set(suiteId, poller);
activeSuiteIdsRef.current.add(suiteId);
setGenerating(true);
if (focus) focusedSuiteIdRef.current = suiteId;
const startedAt = Date.now();
let failCount = 0;
let timeoutWarned = false;
const storageKey = `trialSuiteTasks:${product.id}`;
const legacyStorageKey = `trialSuiteTask:${product.id}`;
try {
sessionStorage.setItem(`trialSuiteTask:${product.id}`, suiteId);
const raw = sessionStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : [];
const ids = Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string') : [];
if (!ids.includes(suiteId)) ids.unshift(suiteId);
sessionStorage.setItem(storageKey, JSON.stringify(ids));
sessionStorage.removeItem(legacyStorageKey);
} catch { /* 私密模式等不可写时不影响当前轮询 */ }
const clearSavedTask = () => {
try {
sessionStorage.removeItem(`trialSuiteTask:${product.id}`);
const raw = sessionStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : [];
const ids = Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string' && id !== suiteId) : [];
if (ids.length) sessionStorage.setItem(storageKey, JSON.stringify(ids));
else sessionStorage.removeItem(storageKey);
sessionStorage.removeItem(legacyStorageKey);
} catch { /* 忽略 sessionStorage 不可用 */ }
};
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current = null;
setGenerating(false);
if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
stopSuitePolling(suiteId);
activeSuiteIdsRef.current.delete(suiteId);
setActiveSuiteTasks((previous) => previous.filter((task) => task.id !== suiteId));
setGenerating(activeSuiteIdsRef.current.size > 0);
};
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
poller.timer = setTimeout(() => void poll(), delayMs);
};
const poll = async () => {
if (runId !== pollRunRef.current || controller.signal.aborted) return;
if (suitePollersRef.current.get(suiteId)?.controller !== controller || controller.signal.aborted) return;
try {
const s = await getSuite(suiteId, { signal: controller.signal });
if (runId !== pollRunRef.current) return;
setSuite(s);
if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
setActiveSuiteTasks((previous) => {
const next = previous.filter((task) => task.id !== suiteId);
return ['done', 'partial', 'failed', 'cancelled'].includes(s.status) ? next : [...next, s];
});
if (focusedSuiteIdRef.current === suiteId) setSuite(s);
failCount = 0;
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
@@ -393,7 +439,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。
schedule(3000);
} catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
if (controller.signal.aborted || suitePollersRef.current.get(suiteId)?.controller !== controller) return;
failCount += 1;
if (failCount >= 3) {
finish();
@@ -475,25 +521,41 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
schedule(0);
};
// 刷新试算页后自动恢复当前商品仍在运行的套图任务;服务重启导致的任务丢失会按常规错误提示处理。
// 刷新试算页后恢复当前商品的所有运行任务,而不是仅恢复最新一条。
useEffect(() => {
let savedTaskId = '';
let savedTaskIds: string[] = [];
try {
savedTaskId = sessionStorage.getItem(`trialSuiteTask:${product.id}`) || '';
const raw = sessionStorage.getItem(`trialSuiteTasks:${product.id}`);
const parsed = raw ? JSON.parse(raw) : [];
savedTaskIds = Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
// 兼容旧版本只保存单个任务 ID 的会话记录。
if (savedTaskIds.length === 0) {
const legacy = sessionStorage.getItem(`trialSuiteTask:${product.id}`);
if (legacy) savedTaskIds = [legacy];
}
} catch { /* sessionStorage 不可用时跳过恢复 */ }
if (savedTaskId) {
setGenerating(true);
pollSuite(savedTaskId);
if (savedTaskIds.length > 0) {
void Promise.all(savedTaskIds.map((id) => getSuite(id).catch(() => null))).then((tasks) => {
const recoverable = tasks.filter((task): task is SuiteInfo => task !== null);
if (recoverable.length === 0) return;
focusedSuiteIdRef.current = recoverable[0].id;
setSuite(recoverable[0]);
recoverable.forEach((task, index) => pollSuite(task.id, index === 0));
});
} else {
void listProductSuites(product.id, true).then((active) => {
const task = active[0];
if (!task) return;
setSuite(task);
setGenerating(true);
pollSuite(task.id);
if (active.length === 0) return;
focusedSuiteIdRef.current = active[0].id;
setSuite(active[0]);
active.forEach((task, index) => pollSuite(task.id, index === 0));
}).catch(() => { /* 网络失败时不打断试算页使用 */ });
}
return () => stopPolling();
return () => {
stopPolling();
activeSuiteIdsRef.current.clear();
setActiveSuiteTasks([]);
setGenerating(false);
};
// 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product.id]);
@@ -531,7 +593,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
message.warning('出图方案的张数都是 0');
return;
}
if (activePlan.some((item) => item.kind === 'size_chart') && !hasAnyDimensions(dimensions)) {
message.warning('方案包含尺寸标注图,请先在右侧填写至少一项商品尺寸及单位,或将该项数量设为 0');
return;
}
setGenerating(true);
focusedSuiteIdRef.current = null;
setSuite(null);
try {
const { suite_id } = await generateSuite({
@@ -543,6 +610,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
style_set: styleSet,
style_prompt: currentStylePrompt || null,
requirements: requirements.trim() || null,
dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
plan: activePlan,
platform: 'ozon',
model,
@@ -561,9 +629,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
try {
const next = await cancelSuite(suite.id);
setSuite(next);
stopPolling();
setGenerating(false);
try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* ignore */ }
stopSuitePolling(suite.id);
activeSuiteIdsRef.current.delete(suite.id);
setActiveSuiteTasks((previous) => previous.filter((task) => task.id !== suite.id));
setGenerating(activeSuiteIdsRef.current.size > 0);
try {
const raw = sessionStorage.getItem(`trialSuiteTasks:${product.id}`);
const saved = raw ? JSON.parse(raw) : [];
const ids = Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string' && id !== suite.id) : [];
if (ids.length) sessionStorage.setItem(`trialSuiteTasks:${product.id}`, JSON.stringify(ids));
else sessionStorage.removeItem(`trialSuiteTasks:${product.id}`);
sessionStorage.removeItem(`trialSuiteTask:${product.id}`);
} catch { /* ignore */ }
message.info('已取消生成任务;已完成的图片会保留在生成记录中');
} catch (e) {
message.error(apiErrorMessage(e));
@@ -641,6 +718,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
image_stats: stats,
platform: 'ozon',
requirements: requirements.trim() || null,
dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
});
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
setPlan(data.items);
@@ -686,7 +764,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
});
};
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,统一放在 ZIP 根目录) ──
const handleExportResult = async () => {
if (resultImages.length === 0) {
message.warning('暂无生图结果可导出');
@@ -705,7 +783,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
variantName: img.variantName,
key: img.key,
}));
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images, flat: true });
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
@@ -725,6 +803,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
});
const name = cleanFilename(img.name || img.key) || '生图结果';
const suffix = (() => {
const mimeExt: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
'image/gif': '.gif', 'image/bmp': '.bmp',
};
const fromMime = mimeExt[blob.type.split(';')[0].toLowerCase()];
if (fromMime) return fromMime;
try {
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
} catch {
@@ -1133,6 +1217,31 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>商品尺寸(长 × 宽 × 高;未知方向可留空)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
{(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}>
<InputNumber
min={0}
precision={2}
controls={false}
style={{ width: '100%' }}
value={dimensions[key]}
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
placeholder={['长', '宽', '高'][index]}
/>
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
</Fragment>
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'cm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
/>
</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
生图要求(优先级最高,强制约束,会覆盖其他设定)
@@ -1218,6 +1327,30 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : (
<>
{otherActiveSuiteTasks.length > 0 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={`另有 ${otherActiveSuiteTasks.length} 个任务仍在生成,结果会自动保留到下方生图记录`}
description={
<Space wrap size={6} style={{ marginTop: 6 }}>
{otherActiveSuiteTasks.map((task) => (
<Button
key={task.id}
size="small"
onClick={() => {
focusedSuiteIdRef.current = task.id;
setSuite(task);
}}
>
查看任务 · {task.completed ?? 0}/{task.total ?? 0}
</Button>
))}
</Space>
}
/>
)}
{suite && (
<>
<div style={{ marginBottom: 8 }}>
@@ -1267,8 +1400,21 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
) : null}
</Space>
</div>
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{resultImages.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
全部生成结果
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(按完成时间倒序 · 最新结果在第一页首位 · {resultImages.length} 张)
</Text>
</Text>
<div style={{ marginTop: 10 }}>
<GeneratedImageGrid
images={suiteResultImages}
images={resultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
@@ -1277,30 +1423,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
{historyResultImages.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
生图记录
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张)
</Text>
</Text>
<div style={{ marginTop: 10 }}>
<GeneratedImageGrid
images={historyResultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
onToggle={toggleResultKey}
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
</div>
</div>
)}
@@ -1321,11 +1443,14 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
open={genModal !== null}
productId={product.id}
source={genModal}
referenceOptions={[
...imgs.map((asset) => ({ url: assetOriginalUrl(asset), name: assetName(asset) })),
...generatedAssets.map((asset) => ({ url: asset.original_url || assetUrl(asset), name: assetName(asset) })),
]}
watermark={watermark.enabled ? watermark : undefined}
onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()}
onSubmitted={(suiteId) => {
setGenerating(true);
pollSuite(suiteId);
}}
/>
+19 -5
View File
@@ -3,7 +3,8 @@ import { CloseOutlined, DeleteOutlined, DownloadOutlined, LoadingOutlined, Reloa
import { Alert, App, Button, Card, Empty, Image, Modal, Popconfirm, Space, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { cancelVideoJob, createVideoJob, deleteVideoJob, listVideoJobs, retryVideoJob, videoDownloadUrl, type VideoJobInfo } from '@/services/suite';
import { cancelVideoJob, createVideoJob, deleteVideoJob, downloadVideo, listVideoJobs, retryVideoJob, type VideoJobInfo } from '@/services/suite';
import { downloadBlob } from '@/utils/file';
const { Text } = Typography;
const urlOf = (asset: ProductAsset) => asset.stored_url || asset.source_url;
@@ -11,12 +12,14 @@ const nameOf = (asset: ProductAsset) => decodeURIComponent((asset.source_url ||
const statusText = (status: VideoJobInfo['status']) => ({ queued: '排队中', running: '合成中', done: '已完成', partial: '部分完成', failed: '失败', cancelled: '已取消' } as const)[status];
const statusColor = (status: VideoJobInfo['status']) => status === 'done' ? 'green' : status === 'failed' ? 'red' : status === 'partial' ? 'orange' : status === 'cancelled' ? 'default' : 'processing';
function VideoCard({ job, removing, onCancel, onDelete, onRetry }: {
function VideoCard({ job, removing, downloading, onCancel, onDelete, onRetry, onDownload }: {
job: VideoJobInfo;
removing: boolean;
downloading: boolean;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onRetry: (id: string) => void;
onDownload: (id: string) => void;
}) {
const active = ['queued', 'running'].includes(job.status);
const done = job.status === 'done' && !!job.result?.url;
@@ -40,7 +43,7 @@ function VideoCard({ job, removing, onCancel, onDelete, onRetry }: {
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0, marginTop: 8 }}>
<Tag color={statusColor(job.status)} style={{ margin: 0, fontSize: 13 }}>{statusText(job.status)}</Tag>
<Text type="secondary" style={{ minWidth: 0, fontSize: 13, whiteSpace: 'nowrap' }}>{job.source_asset_ids.length} 图 · {job.duration_seconds ? `${job.duration_seconds.toFixed(1)} 秒` : `每图 ${job.slide_seconds} 秒`}</Text>
{done && <Button type="link" size="small" icon={<DownloadOutlined />} href={videoDownloadUrl(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>下载 MP4</Button>}
{done && <Button type="link" size="small" icon={<DownloadOutlined />} loading={downloading} onClick={() => onDownload(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>下载 MP4</Button>}
{failed && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={() => onRetry(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>重试</Button>}
</div>
{job.error && <Text type="danger" ellipsis={{ tooltip: job.error }} style={{ display: 'block', fontSize: 12, marginTop: 5 }}>{job.error}</Text>}
@@ -53,6 +56,7 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
const [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [removingId, setRemovingId] = useState<string | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [jobs, setJobs] = useState<VideoJobInfo[]>([]);
const candidates = useMemo(() => assets.filter((asset) => asset.type === 'img' && asset.status === 'uploaded' && !!urlOf(asset)).sort((a, b) => Number(b.group_key === 'generated') - Number(a.group_key === 'generated') || (b.created_at || '').localeCompare(a.created_at || '')), [assets]);
@@ -75,16 +79,26 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
try { await deleteVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
};
const retry = async (id: string) => { try { await retryVideoJob(id); await loadJobs(); } catch (error) { message.error(apiErrorMessage(error)); } };
const download = async (id: string) => {
setDownloadingId(id);
try {
downloadBlob(await downloadVideo(id), `product-video-${id}.mp4`);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setDownloadingId(null);
}
};
return <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div><Text strong>视频制作</Text><Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>选择 2–5 张图片,合成 810×1080 无音轨轮播 MP4</Text></div>
<Button type="primary" size="small" icon={<VideoCameraOutlined />} disabled={candidates.length < 2} onClick={() => setOpen(true)}>合成视频</Button>
</div>
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} />)}</div>}
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} downloading={downloadingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} onDownload={(id) => void download(id)} />)}</div>}
<Modal open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片">
<Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} />
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 510, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
{selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>}
</Modal>
</div>;
+15 -6
View File
@@ -1,7 +1,7 @@
/**
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
* v2.1 调整:划线价改为「倍数」口径(划线价 = 销售价 × (1 + 倍数%)),替代 v1 的 ÷(1-预留折扣)。
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/划线价倍数/汇率
* 划线价按「划线折扣率」计算:划线价 = 销售价 ÷ (1 - 划线折扣率)。
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/划线折扣率/汇率
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/划线价(¥/₽)
*/
@@ -14,7 +14,7 @@ export interface PricingInput {
weightG: number; // 重量 g
dims: { l: number; w: number; h: number }; // cm
tdPrice: number; // 贴单费用 ¥
lineMultiplier: number; // 划线价倍数 %(划线价 = 销售价 × (1 + 倍数/100))
lineDiscountRate: number; // 划线折扣率 %(划线价 = 销售价 ÷ (1 - 折扣率/100))
fxRate: number; // 汇率 CNY→RUB
}
@@ -28,7 +28,7 @@ export interface PricingResult {
commission: number; // 展示用平台佣金
totalCost: number; // 完全成本
sellingPriceRub: number; // 销售价 ₽
linePriceCny: number; // 划线价 ¥(销售价 × (1 + 倍数))
linePriceCny: number; // 划线价 ¥
linePriceRub: number; // 划线价 ₽
}
@@ -45,7 +45,10 @@ export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number
}
export function calculatePricing(input: PricingInput): PricingResult {
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, lineMultiplier, fxRate } = input;
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, lineDiscountRate, fxRate } = input;
if (!Number.isFinite(lineDiscountRate) || lineDiscountRate < 0 || lineDiscountRate >= 100) {
throw new RangeError('划线折扣率必须大于等于 0 且小于 100');
}
const logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
const profitDecimal = profitRate / 100;
@@ -58,7 +61,7 @@ export function calculatePricing(input: PricingInput): PricingResult {
const totalCost = purchasePrice + logisticsFee + fullCommission;
const sellingPriceRub = sellingPriceCny * fxRate;
const linePriceCny = sellingPriceCny * (1 + lineMultiplier / 100);
const linePriceCny = sellingPriceCny / (1 - lineDiscountRate / 100);
const linePriceRub = linePriceCny * fxRate;
return {
@@ -76,6 +79,12 @@ export function calculatePricing(input: PricingInput): PricingResult {
};
}
/** 将历史「划线价倍数」迁移为等价的「划线折扣率」。 */
export function legacyLineMultiplierToDiscountRate(lineMultiplier: number): number {
const multiplier = Math.max(0, Number.isFinite(lineMultiplier) ? lineMultiplier : 0);
return (multiplier / (100 + multiplier)) * 100;
}
/** 尺寸/物流等级建议(纯提示,不阻断) */
export function validateLogisticsLevel(sellingPriceRub: number, level: LogisticsLevel): string {
if (sellingPriceRub > 140 && level === 'low') return '售价较高,建议选高等级物流';
+11 -1
View File
@@ -11,7 +11,17 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
// responseType: 'blob' 也会把 JSON 错误体包装成 Blob,先还原 detail。
const data = error.response?.data;
const contentType = error.response?.headers?.['content-type'] || data?.type || '';
if (data instanceof Blob && contentType.includes('json')) {
try {
error.response.data = JSON.parse(await data.text());
} catch {
// 保留原始响应,由调用方按普通请求错误处理。
}
}
if (envConfig.debug) {
console.error('请求失败:', error.response?.data || error.message);
}
+46 -9
View File
@@ -32,7 +32,10 @@ export interface PlanItem {
}
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) => ({
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS
.filter((t) => t.value !== 'multi_scene')
.slice(0, 7)
.map((t) => ({
kind: t.value,
title: t.label,
detail: '',
@@ -41,13 +44,21 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) =
variant_name: null,
}));
/** 商品外廓尺寸;单位可选 mm 或 cm。 */
export interface ProductDimensions {
length?: number;
width?: number;
height?: number;
unit?: 'mm' | 'cm';
}
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
{ value: 2, label: '清新明亮', prompt: 'Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood' },
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent' },
{ value: 1, label: '北欧极简', prompt: 'Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 2, label: '清新明亮', prompt: 'Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 4, label: '暖调生活', prompt: 'Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction' },
] as const;
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
@@ -100,6 +111,7 @@ export interface SuitePlanPayload {
image_stats: Record<string, number>;
platform: string;
requirements?: string | null;
dimensions?: ProductDimensions | null;
}
export interface SuiteGeneratePayload {
@@ -113,15 +125,18 @@ export interface SuiteGeneratePayload {
platform: string;
model?: string | null;
watermark?: WatermarkPayload;
dimensions?: ProductDimensions | null;
}
export interface SuiteImageInfo {
item_id: string;
asset_id?: string | null;
type_id: string;
name: string;
url: string;
status: string;
error?: string | null;
completed_at?: string | null;
}
export interface SuiteInfo {
@@ -185,14 +200,32 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload {
product_id?: string;
image_url: string;
/** 明确的生成底图;辅助图始终在其后。 */
main_image_url?: string;
reference_image_urls?: string[];
/** 兼容旧接口的单张底图;新界面使用 main_image_url。 */
image_url?: string;
/** 选中的参考底图,可多张。 */
image_urls?: string[];
prompt?: string;
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
mode?: 'edit' | 'outpaint';
model: string;
ratio?: '3:4' | '1:1' | '4:5';
target_language?: 'ru' | 'ar' | 'en';
translate_text?: boolean;
style_set?: number;
style_prompt?: string | null;
background?: 'auto' | 'white' | 'solid' | 'scene' | 'reference';
background_description?: string | null;
append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string;
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
watermark?: WatermarkPayload;
/** 勾选时将当前图按尺寸标注图任务处理。 */
generate_size_chart?: boolean;
dimensions?: ProductDimensions | null;
}
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
@@ -238,6 +271,8 @@ export function listProductWatermarkJobs(productId: string, activeOnly = false,
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
/** 生成结果导出时忽略图片分组,统一放在 ZIP 的商品根目录。 */
flat?: boolean;
}
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
@@ -305,6 +340,8 @@ export function deleteVideoJob(jobId: string) {
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
}
export function videoDownloadUrl(jobId: string) {
return `/api/video-jobs/${jobId}/download`;
/** 下载视频为 Blob,避免浏览器把 MP4 当作页面打开或预览。 */
export async function downloadVideo(jobId: string): Promise<Blob> {
const res = await apiClient.get(`/video-jobs/${jobId}/download`, { responseType: 'blob' });
return res.data as Blob;
}
+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/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"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./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/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/trialimagegrids.tsx","./src/pages/trial/trialinfopanel.tsx","./src/pages/trial/trialpage.tsx","./src/pages/trial/trialpricingpanel.tsx","./src/pages/trial/trialsuitepanel.tsx","./src/pages/trial/trialtaskcenter.tsx","./src/pages/trial/trialvideomaker.tsx","./src/pages/trial/trialwatermarksettings.tsx","./src/pages/trial/trialimagetypes.ts","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.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"}