Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 897e5585c0 | |||
| dbb2bb80bd | |||
| 27c5daed72 | |||
| d7247ca2da | |||
| 8b54f7ed1e |
@@ -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,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)(后端结构)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 |
|
||||
|
||||
@@ -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 的价格?
|
||||
@@ -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;它们会直接影响表结构、导出格式和后续上品方式。
|
||||
@@ -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,
|
||||
|
||||
@@ -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 直传、库存、审核轮询和前台变体归并均属于后续发布阶段;但字段与接口必须避免妨碍它们。
|
||||
@@ -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 的状态。
|
||||
|
||||
本期不实现该接口;本文仅规定数据与错误归属,避免后续发布阶段重新拆分单商品记录。
|
||||
@@ -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`;在迁移完成前,旧字段只读兼容,禁止双向覆盖。
|
||||
@@ -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 {
|
||||
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
|
||||
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。
|
||||
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,
|
||||
|
||||
@@ -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,97 @@
|
||||
/**
|
||||
* 图文详情资源提取。接口可返回 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'];
|
||||
|
||||
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)))
|
||||
.replace(/\\\//g, '/');
|
||||
}
|
||||
|
||||
function normalizeUrl(raw: string, baseUrl: string): string | null {
|
||||
const value = raw.trim().replace(/^['"]|['"]$/g, '');
|
||||
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value, baseUrl);
|
||||
return /^https?:$/i.test(url.protocol) ? url.href : null;
|
||||
} 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(candidate.replace(/[),;]+$/, ''), 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
|
||||
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/*',
|
||||
|
||||
+17
-5
@@ -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,16 @@ def _is_image(url: str, ctype: str) -> bool:
|
||||
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
|
||||
|
||||
|
||||
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)
|
||||
@@ -148,8 +160,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,
|
||||
@@ -190,5 +202,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}")},
|
||||
)
|
||||
|
||||
+61
-8
@@ -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,
|
||||
@@ -294,7 +330,20 @@ 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。
|
||||
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
|
||||
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="仅扩图不能同时生成尺寸标注图")
|
||||
|
||||
# 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 +353,24 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
||||
platform="ozon",
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
style_set=5,
|
||||
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
|
||||
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5,
|
||||
request_payload={
|
||||
"context": {"title": "AI 精修"},
|
||||
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
|
||||
"style_prompt": 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,
|
||||
|
||||
+20
-2
@@ -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="mm", 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):
|
||||
@@ -166,10 +177,15 @@ 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 代下)")
|
||||
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
|
||||
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
|
||||
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
|
||||
mode: Literal["edit", "outpaint"] = Field(
|
||||
default="edit",
|
||||
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
|
||||
)
|
||||
model: str = Field(default="nano-banana-2")
|
||||
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
|
||||
after_asset_id: str | None = Field(
|
||||
@@ -177,6 +193,8 @@ class ImageEditSingleRequest(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
@@ -84,8 +86,8 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
|
||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||
return "1536x2048" if ratio == "3:4" else "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 "1152x1536" if ratio == "3:4" else "1024x1024"
|
||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
|
||||
if model.startswith("wan2.6"):
|
||||
@@ -141,6 +143,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:
|
||||
@@ -562,6 +633,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 +641,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))
|
||||
|
||||
@@ -650,6 +724,10 @@ async def advance_generation(
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 +22,7 @@ from services.jobs import (
|
||||
recover_expired_leases,
|
||||
)
|
||||
from services.prompts import build_context, build_prompt
|
||||
from services.prompts.common import requirements_block
|
||||
from services.storage import get_storage
|
||||
from services.suite_service import append_generated_asset
|
||||
from services.tasks import TaskImage
|
||||
@@ -152,6 +153,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 +191,43 @@ async def process_item(claim: ClaimedItem) -> None:
|
||||
"pcore": "gpt-image-2",
|
||||
}.get(provider, settings.ark_image_model)
|
||||
try:
|
||||
refs = _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="")
|
||||
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 +236,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"):
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
+161
-20
@@ -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("规划器未返回有效方案项")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 "mm") if isinstance(dimensions, dict) else "mm"
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:]}")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""单图导出响应头回归测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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_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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""后台生图 Worker 的取消竞态回归测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+123
-36
@@ -1,11 +1,119 @@
|
||||
#!/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=""
|
||||
COLLECTOR_WATCH_PID=""
|
||||
COLLECTOR_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 "$COLLECTOR_WATCH_PID"
|
||||
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_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 +140,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 +148,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 +159,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 +175,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 +189,11 @@ 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)"
|
||||
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 +208,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 "服务已停止。按回车关闭窗口…"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd';
|
||||
import { CloseOutlined } 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, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -11,6 +11,8 @@ interface Props {
|
||||
productId: string;
|
||||
/** 待生成的源图(采集图或生成图) */
|
||||
source: { url: string; name: string; afterAssetId?: string } | null;
|
||||
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
|
||||
referenceOptions: Array<{ url: string; name: string }>;
|
||||
onClose: () => void;
|
||||
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
||||
onGenerated?: (url: string) => void;
|
||||
@@ -24,13 +26,17 @@ interface Props {
|
||||
* 单张 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 [prompt, setPrompt] = useState('');
|
||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
|
||||
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
|
||||
const [generateSizeChart, setGenerateSizeChart] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [resultUrl, setResultUrl] = useState('');
|
||||
const [taskId, setTaskId] = useState('');
|
||||
const [taskError, setTaskError] = useState('');
|
||||
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set());
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pollAbortRef = useRef<AbortController | null>(null);
|
||||
const pollRunRef = useRef(0);
|
||||
@@ -48,12 +54,16 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
||||
useEffect(() => {
|
||||
if (open && !activeTaskRef.current) {
|
||||
setPrompt('');
|
||||
setMode('edit');
|
||||
setDimensions({ unit: 'mm' });
|
||||
setGenerateSizeChart(false);
|
||||
setResultUrl('');
|
||||
setGenerating(false);
|
||||
setTaskId('');
|
||||
setTaskError('');
|
||||
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set());
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, source?.url]);
|
||||
|
||||
useEffect(() => () => stopPolling(), []);
|
||||
|
||||
@@ -126,18 +136,35 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!source) return;
|
||||
if (selectedReferenceUrls.size === 0) {
|
||||
message.warning('请至少选择一张参考底图');
|
||||
return;
|
||||
}
|
||||
if (mode === 'outpaint' && selectedReferenceUrls.size !== 1) {
|
||||
message.warning('仅扩图只能选择一张参考底图');
|
||||
return;
|
||||
}
|
||||
const hasAnyDimensions = [dimensions.length, dimensions.width, dimensions.height]
|
||||
.some((value) => typeof value === 'number' && value > 0);
|
||||
if (generateSizeChart && !hasAnyDimensions) {
|
||||
message.warning('生成尺寸标注图前,请至少填写一项商品尺寸');
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
setResultUrl('');
|
||||
setTaskError('');
|
||||
try {
|
||||
const r = await imageEditSingle({
|
||||
product_id: productId,
|
||||
image_url: source.url,
|
||||
image_urls: Array.from(selectedReferenceUrls),
|
||||
prompt: prompt.trim(),
|
||||
mode,
|
||||
model,
|
||||
append: true,
|
||||
after_asset_id: source.afterAssetId,
|
||||
watermark: watermark?.enabled ? watermark : undefined,
|
||||
generate_size_chart: generateSizeChart,
|
||||
dimensions: hasAnyDimensions ? dimensions : null,
|
||||
});
|
||||
setTaskId(r.suite_id);
|
||||
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
||||
@@ -169,6 +196,36 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
||||
}
|
||||
};
|
||||
|
||||
const referenceImages = Array.from(
|
||||
new Map(
|
||||
[...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])]
|
||||
.filter((image) => image.url)
|
||||
.map((image) => [image.url, image]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
const toggleReference = (url: string) => {
|
||||
if (generating) return;
|
||||
setSelectedReferenceUrls((previous) => {
|
||||
if (mode === 'outpaint') return new Set([url]);
|
||||
const next = new Set(previous);
|
||||
if (next.has(url)) next.delete(url); else next.add(url);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const changeMode = (nextMode: 'edit' | 'outpaint') => {
|
||||
setMode(nextMode);
|
||||
if (nextMode === 'outpaint') {
|
||||
setGenerateSizeChart(false);
|
||||
setModel('gpt-image-2@pcore');
|
||||
setSelectedReferenceUrls((previous) => {
|
||||
const first = previous.values().next().value as string | undefined;
|
||||
return new Set(first ? [first] : (source?.url ? [source.url] : []));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
@@ -197,25 +254,106 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
value={mode}
|
||||
buttonStyle="solid"
|
||||
disabled={generating}
|
||||
onChange={(e) => changeMode(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="edit">AI 编辑</Radio.Button>
|
||||
<Radio.Button value="outpaint">仅扩图至 3:4</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 7 }}>
|
||||
<Text strong style={{ fontSize: 13 }}>参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已选 {selectedReferenceUrls.size} 张</Text>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 8, maxHeight: 190, overflowY: 'auto', padding: 2 }}>
|
||||
{referenceImages.map((image) => {
|
||||
const selected = selectedReferenceUrls.has(image.url);
|
||||
return (
|
||||
<button
|
||||
key={image.url}
|
||||
type="button"
|
||||
title={`${selected ? '取消选择' : '选择'}:${image.name}`}
|
||||
onClick={() => toggleReference(image.url)}
|
||||
disabled={generating}
|
||||
style={{ position: 'relative', padding: 2, border: `2px solid ${selected ? '#1677ff' : '#e5e7eb'}`, borderRadius: 7, background: selected ? '#e6f4ff' : '#fff', cursor: generating ? 'not-allowed' : 'pointer', aspectRatio: '1 / 1', overflow: 'hidden' }}
|
||||
>
|
||||
<img src={image.url} alt={image.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', borderRadius: 4 }} />
|
||||
{selected && <span style={{ position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: '50%', background: '#1677ff', color: '#fff', fontSize: 12, lineHeight: '18px' }}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ width: 260, flexShrink: 0 }}>
|
||||
<Image
|
||||
src={source?.url}
|
||||
alt="原图"
|
||||
alt="当前图片"
|
||||
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
|
||||
{mode === 'edit' && <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: generateSizeChart ? 12 : 0 }}>
|
||||
<Checkbox
|
||||
checked={generateSizeChart}
|
||||
onChange={(e) => setGenerateSizeChart(e.target.checked)}
|
||||
disabled={generating}
|
||||
>
|
||||
生成尺寸标注图(未知方向留空)
|
||||
</Checkbox>
|
||||
</div>}
|
||||
{mode === 'edit' && generateSizeChart && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{(['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]}
|
||||
disabled={generating}
|
||||
/>
|
||||
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
|
||||
</Fragment>
|
||||
))}
|
||||
<Select
|
||||
style={{ width: 76, flexShrink: 0 }}
|
||||
value={dimensions.unit ?? 'mm'}
|
||||
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
|
||||
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
|
||||
disabled={generating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={mode === 'outpaint'
|
||||
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
|
||||
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
|
||||
disabled={generating}
|
||||
/>
|
||||
{mode === 'outpaint' && (
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
|
||||
系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
|
||||
<Select
|
||||
@@ -224,7 +362,9 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={generating}
|
||||
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
||||
options={IMAGE_MODEL_OPTIONS
|
||||
.filter((m) => mode !== 'outpaint' || m.value === 'gpt-image-2@pcore')
|
||||
.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
@@ -234,11 +374,13 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
|
||||
<Button type="primary" loading={generating} onClick={onGenerate}>
|
||||
提交任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert
|
||||
style={{ marginTop: 16 }}
|
||||
type={taskError && !generating ? 'error' : 'info'}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 且 < 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}
|
||||
|
||||
@@ -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: 'mm' });
|
||||
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);
|
||||
@@ -233,6 +240,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
}, [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 +267,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 +352,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 +425,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 +507,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 +579,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 +596,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 +615,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 +704,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 +750,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
});
|
||||
};
|
||||
|
||||
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
|
||||
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,统一放在 ZIP 根目录) ──
|
||||
const handleExportResult = async () => {
|
||||
if (resultImages.length === 0) {
|
||||
message.warning('暂无生图结果可导出');
|
||||
@@ -705,7 +769,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));
|
||||
@@ -1133,6 +1197,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 ?? 'mm'}
|
||||
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 +1307,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 }}>
|
||||
@@ -1321,11 +1434,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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,13 +79,23 @@ 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>}
|
||||
|
||||
@@ -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 '售价较高,建议选高等级物流';
|
||||
|
||||
@@ -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,6 +44,14 @@ 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' },
|
||||
@@ -100,6 +111,7 @@ export interface SuitePlanPayload {
|
||||
image_stats: Record<string, number>;
|
||||
platform: string;
|
||||
requirements?: string | null;
|
||||
dimensions?: ProductDimensions | null;
|
||||
}
|
||||
|
||||
export interface SuiteGeneratePayload {
|
||||
@@ -113,6 +125,7 @@ export interface SuiteGeneratePayload {
|
||||
platform: string;
|
||||
model?: string | null;
|
||||
watermark?: WatermarkPayload;
|
||||
dimensions?: ProductDimensions | null;
|
||||
}
|
||||
|
||||
export interface SuiteImageInfo {
|
||||
@@ -185,14 +198,22 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
|
||||
|
||||
export interface ImageEditSinglePayload {
|
||||
product_id?: string;
|
||||
image_url: string;
|
||||
/** 兼容旧接口的单张底图;新界面使用 image_urls。 */
|
||||
image_url?: string;
|
||||
/** 选中的参考底图,可多张。 */
|
||||
image_urls?: string[];
|
||||
prompt?: string;
|
||||
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
|
||||
mode?: 'edit' | 'outpaint';
|
||||
model: string;
|
||||
append?: boolean;
|
||||
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
|
||||
after_asset_id?: string;
|
||||
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
|
||||
watermark?: WatermarkPayload;
|
||||
/** 勾选时将当前图按尺寸标注图任务处理。 */
|
||||
generate_size_chart?: boolean;
|
||||
dimensions?: ProductDimensions | null;
|
||||
}
|
||||
|
||||
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
|
||||
@@ -238,6 +259,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 +328,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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user