Compare commits
6 Commits
feat-2.1.1
...
feat-2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa7b6bb8f | |||
| 7f1503294f | |||
| 1c4fe1a836 | |||
| 897e5585c0 | |||
| dbb2bb80bd | |||
| 27c5daed72 |
@@ -48,7 +48,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
|||||||
|
|
||||||
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
|
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
|
||||||
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
|
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
|
||||||
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
|
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits`;对于 `gpt-image-2`,`images` 必须传 **`[{"image_url": "data:image/...;base64,..."}]` 对象数组**。Pcore 通用文档也描述字符串数组,但该模型通道会异步校验 `images[].image_url`,以实际错误为准。均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 需使用模型支持的尺寸或比例。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
|
||||||
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
|
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
|
||||||
|
|
||||||
## 4. AI 规划张数策略(`planner.py`)
|
## 4. AI 规划张数策略(`planner.py`)
|
||||||
|
|||||||
@@ -56,11 +56,11 @@
|
|||||||
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
|
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
|
||||||
|
|
||||||
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
|
输入参数(存 `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 包装字段,变化即重算)。
|
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
|
||||||
|
|
||||||
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
|
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
|
||||||
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
|
> 划线价按「划线折扣率」计算:`划线价 = 销售价 ÷ (1 - 划线折扣率)`。例如折扣率 50%、销售价 ¥50 时,划线价为 ¥100。折扣率必须 ≥ 0 且 < 100%。
|
||||||
|
|
||||||
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
|
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
|
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
|
||||||
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
|
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
|
||||||
完全成本 = 进货价 + 物流费 + 平台总抽成
|
完全成本 = 进货价 + 物流费 + 平台总抽成
|
||||||
预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价
|
划线价¥ = 销售价 ÷ (1 - 划线折扣率);差额 = 划线价 - 销售价
|
||||||
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
|
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
// product.pricing(PriceInfoPanel 字段的超集,向后兼容)
|
// product.pricing(PriceInfoPanel 字段的超集,向后兼容)
|
||||||
{
|
{
|
||||||
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3,
|
"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,
|
"logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63,
|
||||||
"fullCommission": 15.02, "totalCost": 67.14,
|
"fullCommission": 15.02, "totalCost": 67.14,
|
||||||
"sellingPriceCny": 77.51, "linePriceCny": 155.02,
|
"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)
|
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
|
||||||
if (msg?.action === 'fetchText') {
|
if (msg?.action === 'fetchText') {
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (msg.referer) headers['Referer'] = msg.referer;
|
|
||||||
fetch(msg.url, {
|
fetch(msg.url, {
|
||||||
headers,
|
|
||||||
credentials: msg.credentials ? 'include' : 'omit',
|
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) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|||||||
@@ -300,6 +300,11 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setReporting(true);
|
setReporting(true);
|
||||||
try {
|
try {
|
||||||
|
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
|
||||||
|
// 超过 100 张时,background 会自动分批写入同一个商品,用户无需手动拆分。
|
||||||
|
const imagesToReport = selectedKeys.size > 0
|
||||||
|
? allImages.filter(img => selectedKeys.has(img.key))
|
||||||
|
: allImages;
|
||||||
const data = await send<{ product_id: string }>('reportProduct', {
|
const data = await send<{ product_id: string }>('reportProduct', {
|
||||||
reportBaseUrl: settings.reportBaseUrl,
|
reportBaseUrl: settings.reportBaseUrl,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -310,8 +315,7 @@ const App: React.FC = () => {
|
|||||||
collectedAt: result.scannedAt,
|
collectedAt: result.scannedAt,
|
||||||
},
|
},
|
||||||
texts: editedTexts(),
|
texts: editedTexts(),
|
||||||
// 全量上报采集图(main/sku/detail/video),试算页需要完整素材
|
images: imagesToReport.map(img => ({
|
||||||
images: result.images.map(img => ({
|
|
||||||
groupKey: img.groupKey,
|
groupKey: img.groupKey,
|
||||||
groupName: img.groupName,
|
groupName: img.groupName,
|
||||||
variantName: img.variantName ?? null,
|
variantName: img.variantName ?? null,
|
||||||
|
|||||||
@@ -38,27 +38,27 @@ export const STYLE_SET_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: 1,
|
value: 1,
|
||||||
label: '北欧极简',
|
label: '北欧极简',
|
||||||
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
|
prompt: 'Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 2,
|
value: 2,
|
||||||
label: '清新明亮',
|
label: '清新明亮',
|
||||||
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
|
prompt: 'Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 3,
|
value: 3,
|
||||||
label: '高级感深色',
|
label: '高级感深色',
|
||||||
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
|
prompt: 'Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 4,
|
value: 4,
|
||||||
label: '暖调生活',
|
label: '暖调生活',
|
||||||
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
|
prompt: 'Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 5,
|
value: 5,
|
||||||
label: '纯净棚拍',
|
label: '纯净棚拍',
|
||||||
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
|
prompt: 'Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import type { TextMaterial } from '../collector/merge';
|
import type { TextMaterial } from '../collector/merge';
|
||||||
|
|
||||||
export interface ReportProductPayload {
|
export interface ReportProductPayload {
|
||||||
|
/** 后续批次写入首次上报创建的商品;调用方首次上报无需提供。 */
|
||||||
|
product_id?: string;
|
||||||
source: {
|
source: {
|
||||||
platform: string;
|
platform: string;
|
||||||
itemId: string | null;
|
itemId: string | null;
|
||||||
@@ -22,16 +24,24 @@ export interface ReportProductPayload {
|
|||||||
}>;
|
}>;
|
||||||
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
|
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
|
||||||
refererOrigin?: string;
|
refererOrigin?: string;
|
||||||
|
/** 多批上报时,仅最后一批启动素材下载,避免同一商品出现并发重复下载。 */
|
||||||
|
process_assets?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportProductResult {
|
export interface ReportProductResult {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
stage?: string;
|
stage?: string;
|
||||||
assets_queued?: number;
|
assets_queued?: number;
|
||||||
|
assets_skipped?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */
|
/**
|
||||||
export async function reportProduct(
|
* 后端单次最多接收 100 个素材;插件在此自动拆批,用户无需手动删减或分批上报。
|
||||||
|
* 所有批次共用首次返回的 product_id,且只在最后一批启动下载队列。
|
||||||
|
*/
|
||||||
|
const MATERIALS_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
async function postMaterials(
|
||||||
reportBaseUrl: string,
|
reportBaseUrl: string,
|
||||||
payload: ReportProductPayload,
|
payload: ReportProductPayload,
|
||||||
): Promise<ReportProductResult> {
|
): Promise<ReportProductResult> {
|
||||||
@@ -44,3 +54,43 @@ export async function reportProduct(
|
|||||||
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
|
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
|
||||||
return data as ReportProductResult;
|
return data as ReportProductResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS)。 */
|
||||||
|
export async function reportProduct(
|
||||||
|
reportBaseUrl: string,
|
||||||
|
payload: ReportProductPayload,
|
||||||
|
): Promise<ReportProductResult> {
|
||||||
|
const batches = payload.images.length > 0
|
||||||
|
? Array.from({ length: Math.ceil(payload.images.length / MATERIALS_BATCH_SIZE) }, (_, index) =>
|
||||||
|
payload.images.slice(index * MATERIALS_BATCH_SIZE, (index + 1) * MATERIALS_BATCH_SIZE),
|
||||||
|
)
|
||||||
|
: [[]];
|
||||||
|
|
||||||
|
let productId = payload.product_id;
|
||||||
|
let lastResult: ReportProductResult | null = null;
|
||||||
|
let assetsQueued = 0;
|
||||||
|
let assetsSkipped = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < batches.length; index += 1) {
|
||||||
|
try {
|
||||||
|
const result = await postMaterials(reportBaseUrl, {
|
||||||
|
...payload,
|
||||||
|
product_id: productId,
|
||||||
|
// 文本仅需在首批写入;后续批次只追加素材,避免覆盖人工编辑的信息。
|
||||||
|
texts: index === 0 ? payload.texts : [],
|
||||||
|
images: batches[index],
|
||||||
|
process_assets: index === batches.length - 1,
|
||||||
|
});
|
||||||
|
productId = result.product_id;
|
||||||
|
lastResult = result;
|
||||||
|
assetsQueued += result.assets_queued ?? 0;
|
||||||
|
assetsSkipped += result.assets_skipped ?? 0;
|
||||||
|
} catch (error) {
|
||||||
|
const prefix = batches.length > 1 ? `第 ${index + 1}/${batches.length} 批上报失败:` : '';
|
||||||
|
throw new Error(prefix + (error instanceof Error ? error.message : String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastResult) throw new Error('上报未返回商品信息');
|
||||||
|
return { ...lastResult, assets_queued: assetsQueued, assets_skipped: assetsSkipped };
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface State1688 {
|
|||||||
videos: Array<{ url: string; cover?: string }>;
|
videos: Array<{ url: string; cover?: string }>;
|
||||||
skus: Sku1688[];
|
skus: Sku1688[];
|
||||||
params: Array<{ key: string; value: string }>;
|
params: Array<{ key: string; value: string }>;
|
||||||
|
detailUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
|
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
|
||||||
@@ -150,6 +151,13 @@ export function extract1688State(context: unknown): State1688 | null {
|
|||||||
temp.topCategoryId ? String(temp.topCategoryId) : '',
|
temp.topCategoryId ? String(temp.topCategoryId) : '',
|
||||||
].filter(Boolean);
|
].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;
|
if (!title && mainImages.length === 0 && skus.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -164,5 +172,6 @@ export function extract1688State(context: unknown): State1688 | null {
|
|||||||
videos,
|
videos,
|
||||||
skus,
|
skus,
|
||||||
params,
|
params,
|
||||||
|
detailUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本;
|
||||||
|
* 本模块只解析字符串和 HTML,绝不执行远端脚本。
|
||||||
|
*/
|
||||||
|
import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url';
|
||||||
|
import type { ImageMaterial } from './image';
|
||||||
|
import type { SiteProfile } from '../profiles/types';
|
||||||
|
|
||||||
|
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
|
||||||
|
const IMAGE_EXTENSION_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)$/i;
|
||||||
|
const IMAGE_EXTENSION_IN_PATH_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)\//i;
|
||||||
|
|
||||||
|
function normalizeEscapedText(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||||
|
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||||
|
// 1688/淘宝详情接口常把 HTML 放在 JSON/JS 字符串中。若不还原 \",DOMParser
|
||||||
|
// 会把它编码为 %22,并可能把后面的真实 https URL 误解析成当前 CDN 的路径。
|
||||||
|
.replace(/\\(["'])/g, '$1')
|
||||||
|
.replace(/\\\//g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimImageCandidate(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[),;]+$/, '')
|
||||||
|
// “...jpg/” 是详情字符串错误拼接的常见形态,不是有效图片路径。
|
||||||
|
.replace(/(\.(?:avif|bmp|gif|jpe?g|png|webp))\/+$/i, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复被外层 CDN 路径/转义引号包裹的图片地址,例如:
|
||||||
|
* https://itemcdn.tmall.com/%22https://cbu01.alicdn.com/img/...jpg/
|
||||||
|
* 仅在检测到两个 URL 且内层是标准图片路径时取内层,正常带签名参数的 URL 保持原样。
|
||||||
|
*/
|
||||||
|
function recoverEmbeddedImageUrl(value: string): string | null {
|
||||||
|
let decoded = value;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
// 非法百分号编码时仍按原文解析。
|
||||||
|
}
|
||||||
|
const matches = decoded.match(/https?:\/\/[^\s'"<>\\]+/gi) ?? [];
|
||||||
|
if (matches.length < 2) return null;
|
||||||
|
|
||||||
|
for (const match of matches.reverse()) {
|
||||||
|
const candidate = trimImageCandidate(match);
|
||||||
|
try {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
if (/^https?:$/i.test(url.protocol) && IMAGE_EXTENSION_RE.test(url.pathname)) return url.href;
|
||||||
|
} catch {
|
||||||
|
// 继续检查更外层的候选。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(raw: string, baseUrl: string): string | null {
|
||||||
|
const cleaned = raw.trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
const value = recoverEmbeddedImageUrl(cleaned) ?? cleaned;
|
||||||
|
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, baseUrl);
|
||||||
|
if (!/^https?:$/i.test(url.protocol)) return null;
|
||||||
|
// 不允许图片扩展名后仍跟路径段(如 .jpg/),该 URL 不会指向图片资源。
|
||||||
|
if (IMAGE_EXTENSION_IN_PATH_RE.test(url.pathname)) return null;
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUrl(out: string[], seen: Set<string>, raw: string | null, baseUrl: string): void {
|
||||||
|
if (!raw) return;
|
||||||
|
const url = normalizeUrl(raw, baseUrl);
|
||||||
|
if (!url || seen.has(url)) return;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */
|
||||||
|
export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const decoded = normalizeEscapedText(raw);
|
||||||
|
const doc = new DOMParser().parseFromString(decoded, 'text/html');
|
||||||
|
|
||||||
|
for (const el of Array.from(doc.querySelectorAll('img, source'))) {
|
||||||
|
for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl);
|
||||||
|
const srcset = el.getAttribute('srcset');
|
||||||
|
if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl);
|
||||||
|
}
|
||||||
|
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('[style]'))) {
|
||||||
|
addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
|
||||||
|
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const url = normalizeUrl(trimImageCandidate(candidate), baseUrl);
|
||||||
|
if (!url) continue;
|
||||||
|
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
|
||||||
|
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
|
||||||
|
if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 background service worker 跨域读取详情接口。 */
|
||||||
|
export function fetchDetailText(url: string, referer: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ action: 'fetchText', url, referer, credentials: true },
|
||||||
|
(response: { ok?: boolean; text?: string; error?: string } | undefined) => {
|
||||||
|
const runtimeError = chrome.runtime.lastError;
|
||||||
|
if (runtimeError) return reject(new Error(runtimeError.message));
|
||||||
|
if (!response?.ok || typeof response.text !== 'string') {
|
||||||
|
return reject(new Error(response?.error || '详情接口未返回内容'));
|
||||||
|
}
|
||||||
|
resolve(response.text);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: ImageMaterial[] = [];
|
||||||
|
for (const rawUrl of urls) {
|
||||||
|
const url = toOriginalUrl(rawUrl, profile.originalUrlRules);
|
||||||
|
const key = dedupeKey(url, profile.originalUrlRules);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push({
|
||||||
|
key: `detail-${String(result.length + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl,
|
||||||
|
index: result.length, type: 'img',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -53,6 +53,25 @@ export function queryAllDeep(selectors: string[]): Element[] {
|
|||||||
return out;
|
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[] = [];
|
const warnings: string[] = [];
|
||||||
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
|
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
|
||||||
if (images.length === 0) 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 {
|
return {
|
||||||
platform: profile.id,
|
platform: profile.id,
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
* 1688 采集编排(平台文件):
|
* 1688 采集编排(平台文件):
|
||||||
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
||||||
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
||||||
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
* ② description.detailUrl:直接读取图文详情,不操作用户页面
|
||||||
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
|
* ③ DOM 兜底 + 补充当前已挂载内容
|
||||||
* 详情图改为滚动加载后由 DOM 采集补齐。
|
|
||||||
*/
|
*/
|
||||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
import { waitForAny } from '../dom';
|
||||||
import { collectImages, type ImageMaterial } from '../image';
|
import { collectImages, type ImageMaterial } from '../image';
|
||||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||||
import { toOriginalUrl } from '../url';
|
import { toOriginalUrl } from '../url';
|
||||||
import { readWindowKeys } from '../../bridge/read-window';
|
import { readWindowKeys } from '../../bridge/read-window';
|
||||||
import { extract1688State, type State1688 } from '../1688-state';
|
import { extract1688State, type State1688 } from '../1688-state';
|
||||||
|
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images';
|
||||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||||
import type { SiteProfile } from '../../profiles/types';
|
import type { SiteProfile } from '../../profiles/types';
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
|
|||||||
let primaryImages: ImageMaterial[] = [];
|
let primaryImages: ImageMaterial[] = [];
|
||||||
let source: ScanResult['source'] = 'dom';
|
let source: ScanResult['source'] = 'dom';
|
||||||
let st: State1688 | null = null;
|
let st: State1688 | null = null;
|
||||||
|
const detailWarnings: string[] = [];
|
||||||
|
|
||||||
// ① 桥读 window.context
|
// ① 桥读 window.context
|
||||||
const keys = await readWindowKeys(['context']);
|
const keys = await readWindowKeys(['context']);
|
||||||
@@ -75,11 +76,22 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
source = 'state';
|
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 在这里进结果)
|
// ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。
|
||||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
|
||||||
await autoScrollToBottom();
|
|
||||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||||
const { materials: domTexts, missingRequired } = collectTexts(profile);
|
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 texts = mergeTexts(primaryTexts, domTexts);
|
||||||
const images = mergeImages(primaryImages, domImages, profile);
|
const images = mergeImages(primaryImages, domImages, profile);
|
||||||
const result = finalize(profile, itemId, texts, images, [], source);
|
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(', ')}`);
|
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* ③ 站内页 JSON API(entrypoint-api.bx)
|
* ③ 站内页 JSON API(entrypoint-api.bx)
|
||||||
* ④ DOM data-widget 选择器兜底 + 详情图补充
|
* ④ DOM data-widget 选择器兜底 + 详情图补充
|
||||||
*/
|
*/
|
||||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom';
|
||||||
import { collectImages, type ImageMaterial } from '../image';
|
import { collectImages, type ImageMaterial } from '../image';
|
||||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||||
import { toOriginalUrl, toThumbUrl } from '../url';
|
import { toOriginalUrl, toThumbUrl } from '../url';
|
||||||
@@ -148,6 +148,7 @@ export async function scanOzon(profile: SiteProfile, itemId: string | null): Pro
|
|||||||
|
|
||||||
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
|
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
|
||||||
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||||
|
await activateDetailTab();
|
||||||
await autoScrollToBottom();
|
await autoScrollToBottom();
|
||||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
|
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
|
||||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
||||||
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
||||||
* ② DOM 兜底 + 补充
|
* ② DOM 兜底 + 补充
|
||||||
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
|
* 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。
|
||||||
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
|
|
||||||
*/
|
*/
|
||||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
import { waitForAny } from '../dom';
|
||||||
import { collectImages, type ImageMaterial } from '../image';
|
import { collectImages, type ImageMaterial } from '../image';
|
||||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||||
import { readWindowKeys } from '../../bridge/read-window';
|
import { readWindowKeys } from '../../bridge/read-window';
|
||||||
import { buildFromSSR } from '../ssr-builder';
|
import { buildFromSSR } from '../ssr-builder';
|
||||||
import { taobaoStateFromBridge } from '../taobao-state';
|
import { taobaoStateFromBridge } from '../taobao-state';
|
||||||
|
import { collectTaobaoDetailImages } from '../taobao-detail';
|
||||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||||
import type { SiteProfile } from '../../profiles/types';
|
import type { SiteProfile } from '../../profiles/types';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
|
|||||||
let primaryTexts: TextMaterial[] = [];
|
let primaryTexts: TextMaterial[] = [];
|
||||||
let primaryImages: ImageMaterial[] = [];
|
let primaryImages: ImageMaterial[] = [];
|
||||||
let source: ScanResult['source'] = 'dom';
|
let source: ScanResult['source'] = 'dom';
|
||||||
|
const detailWarnings: string[] = [];
|
||||||
|
|
||||||
// ① 桥读页面全局
|
// ① 桥读页面全局
|
||||||
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
|
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';
|
source = 'ssr';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ② DOM 兜底 + 补充
|
// ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。
|
||||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
if (_itemId) {
|
||||||
await autoScrollToBottom();
|
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);
|
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||||
const { materials: domTexts, missingRequired } = collectTexts(profile);
|
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 texts = mergeTexts(primaryTexts, domTexts);
|
||||||
const images = mergeImages(primaryImages, domImages, profile);
|
const images = mergeImages(primaryImages, domImages, profile);
|
||||||
const result = finalize(profile, _itemId, texts, images, [], source);
|
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(', ')}`);
|
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
|
|||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
warnings.push('未扫描到任何图片/视频');
|
warnings.push('未扫描到任何图片/视频');
|
||||||
}
|
}
|
||||||
// SSR 数据里没有详情图,需要 DOM 补充
|
// SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。
|
||||||
if (stats.detail === undefined) {
|
if (stats.detail === undefined) {
|
||||||
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
|
warnings.push('详情图需补充:页面未提供可读取的图文详情数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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://detail.1688.com/*',
|
||||||
'https://item.taobao.com/*',
|
'https://item.taobao.com/*',
|
||||||
'https://detail.tmall.com/*',
|
'https://detail.tmall.com/*',
|
||||||
|
'https://h5api.m.taobao.com/*',
|
||||||
|
'https://h5api.m.tmall.com/*',
|
||||||
'https://*.alicdn.com/*',
|
'https://*.alicdn.com/*',
|
||||||
// 1688 详情数据 CDN(description.detailUrl)
|
// 1688 详情数据 CDN(description.detailUrl)
|
||||||
'https://itemcdn.tmall.com/*',
|
'https://itemcdn.tmall.com/*',
|
||||||
|
|||||||
+26
-6
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
|
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
|
||||||
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
|
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
|
||||||
ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
|
采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可
|
||||||
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
|
请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ class ExportImageItem(BaseModel):
|
|||||||
class ExportImagesRequest(BaseModel):
|
class ExportImagesRequest(BaseModel):
|
||||||
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
||||||
images: list[ExportImageItem] = Field(min_length=1)
|
images: list[ExportImageItem] = Field(min_length=1)
|
||||||
|
flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录")
|
||||||
|
|
||||||
|
|
||||||
def _clean(name: str) -> str:
|
def _clean(name: str) -> str:
|
||||||
@@ -81,6 +82,23 @@ def _is_image(url: str, ctype: str) -> bool:
|
|||||||
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
|
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
def _image_content_type(data: bytes, url: str, ctype: str) -> str | None:
|
||||||
|
"""存储对象无扩展名且 MIME 为 octet-stream 时,按图片文件头识别格式。"""
|
||||||
|
if ctype.split(";")[0].strip().lower().startswith("image/"):
|
||||||
|
return ctype
|
||||||
|
if data.startswith(b"\xff\xd8\xff"):
|
||||||
|
return "image/jpeg"
|
||||||
|
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return "image/png"
|
||||||
|
if data.startswith((b"GIF87a", b"GIF89a")):
|
||||||
|
return "image/gif"
|
||||||
|
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||||
|
return "image/webp"
|
||||||
|
if data.startswith(b"BM"):
|
||||||
|
return "image/bmp"
|
||||||
|
return ctype if _is_image(url, ctype) else None
|
||||||
|
|
||||||
|
|
||||||
def _download_disposition(filename: str) -> str:
|
def _download_disposition(filename: str) -> str:
|
||||||
"""生成兼容中文文件名的下载响应头。
|
"""生成兼容中文文件名的下载响应头。
|
||||||
|
|
||||||
@@ -137,7 +155,8 @@ async def export_images(req: ExportImagesRequest):
|
|||||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
continue # 单张下载失败不中断整包
|
continue # 单张下载失败不中断整包
|
||||||
if not _is_image(img.url, ctype):
|
ctype = _image_content_type(data, img.url, ctype)
|
||||||
|
if ctype is None:
|
||||||
continue
|
continue
|
||||||
total_source_bytes += len(data)
|
total_source_bytes += len(data)
|
||||||
if total_source_bytes > settings.export_zip_max_bytes:
|
if total_source_bytes > settings.export_zip_max_bytes:
|
||||||
@@ -159,8 +178,8 @@ async def export_images(req: ExportImagesRequest):
|
|||||||
filename = f"{stem}-{n}{ext}"
|
filename = f"{stem}-{n}{ext}"
|
||||||
used.add(filename)
|
used.add(filename)
|
||||||
|
|
||||||
group = _clean(img.groupName) or "图片"
|
archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}"
|
||||||
zf.writestr(f"{root}/{group}/{filename}", data)
|
zf.writestr(archive_path, data)
|
||||||
if archive.tell() > settings.export_zip_max_bytes:
|
if archive.tell() > settings.export_zip_max_bytes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
@@ -191,7 +210,8 @@ async def export_image(img: ExportImageItem):
|
|||||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=422, detail="图片下载失败") from exc
|
raise HTTPException(status_code=422, detail="图片下载失败") from exc
|
||||||
if not _is_image(img.url, ctype):
|
ctype = _image_content_type(data, img.url, ctype)
|
||||||
|
if ctype is None:
|
||||||
raise HTTPException(status_code=400, detail="该地址不是图片")
|
raise HTTPException(status_code=400, detail="该地址不是图片")
|
||||||
|
|
||||||
ext = _ext(img.url, ctype)
|
ext = _ext(img.url, ctype)
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ async def create_materials(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(product)
|
await db.refresh(product)
|
||||||
|
|
||||||
if queued:
|
# 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。
|
||||||
|
if req.process_assets:
|
||||||
background.add_task(process_product_assets, str(product.id))
|
background.add_task(process_product_assets, str(product.id))
|
||||||
return MaterialsResponse(
|
return MaterialsResponse(
|
||||||
product_id=str(product.id),
|
product_id=str(product.id),
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ async def list_assets(
|
|||||||
"width": a.width,
|
"width": a.width,
|
||||||
"height": a.height,
|
"height": a.height,
|
||||||
"error": a.error,
|
"error": a.error,
|
||||||
|
"created_at": a.created_at,
|
||||||
}
|
}
|
||||||
for a in rows
|
for a in rows
|
||||||
]
|
]
|
||||||
|
|||||||
+82
-11
@@ -9,6 +9,7 @@ watermark + services/prompts),任务持久化到数据库,由独立 Worker
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -49,6 +50,41 @@ from services.suite_service import texts_to_raw, validate_model
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["suite"])
|
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]:
|
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
||||||
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
|
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
|
||||||
@@ -64,7 +100,7 @@ def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iter
|
|||||||
@router.post("/suite/plan", response_model=PlanResponse)
|
@router.post("/suite/plan", response_model=PlanResponse)
|
||||||
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
|
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
|
||||||
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
|
"""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"):
|
if not product_info.get("title"):
|
||||||
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
||||||
try:
|
try:
|
||||||
@@ -140,7 +176,7 @@ async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
|
|||||||
ratio=spec["ratio"],
|
ratio=spec["ratio"],
|
||||||
style_set=req.style_set,
|
style_set=req.style_set,
|
||||||
request_payload={
|
request_payload={
|
||||||
"context": texts_to_raw(req.texts),
|
"context": _with_dimensions(texts_to_raw(req.texts), req.dimensions),
|
||||||
"style_prompt": req.style_prompt,
|
"style_prompt": req.style_prompt,
|
||||||
"requirements": req.requirements,
|
"requirements": req.requirements,
|
||||||
"watermark": req.watermark.model_dump() if req.watermark else None,
|
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||||
@@ -280,12 +316,15 @@ async def download_suite_zip(suite_id: str):
|
|||||||
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
|
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
|
||||||
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
|
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
structured_edit = bool(req.main_image_url and req.main_image_url.strip())
|
||||||
|
if structured_edit and req.model not in {"gpt-image-2", "gpt-image-2@pcore"}:
|
||||||
|
raise HTTPException(status_code=400, detail="单张 AI 编辑仅支持 Pcore gpt-image-2")
|
||||||
|
provider_name = "pcore" if structured_edit else resolve_provider(req.model, None, settings.image_provider)
|
||||||
if provider_name not in GENERATORS:
|
if provider_name not in GENERATORS:
|
||||||
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
|
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
|
||||||
validate_model(provider_name, req.model)
|
validate_model(provider_name, req.model)
|
||||||
|
|
||||||
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
|
spec = {"lang": req.target_language if structured_edit else "ru", "ratio": req.ratio if structured_edit else "3:4"}
|
||||||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||||||
product_id = (req.product_id or "").strip() or None
|
product_id = (req.product_id or "").strip() or None
|
||||||
if product_id:
|
if product_id:
|
||||||
@@ -294,7 +333,29 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||||
|
|
||||||
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
|
||||||
|
if structured_edit:
|
||||||
|
ref_urls = list(dict.fromkeys(url.strip() for url in [req.main_image_url, *req.reference_image_urls] if url and url.strip()))
|
||||||
|
if len(ref_urls) > 3:
|
||||||
|
raise HTTPException(status_code=400, detail="辅助参考图最多两张")
|
||||||
|
else:
|
||||||
|
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
|
||||||
|
if not ref_urls:
|
||||||
|
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
|
||||||
|
if req.mode == "outpaint":
|
||||||
|
if provider_name != "pcore":
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2")
|
||||||
|
if len(ref_urls) != 1:
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
|
||||||
|
if req.generate_size_chart:
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
|
||||||
|
if structured_edit and req.ratio != "3:4":
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图只支持 3:4")
|
||||||
|
if structured_edit and req.mode == "edit" and req.background == "reference" and len(ref_urls) < 2:
|
||||||
|
raise HTTPException(status_code=400, detail="参考图背景需要选择一张辅助参考图")
|
||||||
|
|
||||||
|
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||||
|
is_size_chart = bool(req.generate_size_chart)
|
||||||
task = await create_image_job(
|
task = await create_image_job(
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
kind="image_edit",
|
kind="image_edit",
|
||||||
@@ -304,20 +365,30 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
|||||||
platform="ozon",
|
platform="ozon",
|
||||||
lang=spec["lang"],
|
lang=spec["lang"],
|
||||||
ratio=spec["ratio"],
|
ratio=spec["ratio"],
|
||||||
style_set=5,
|
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
|
||||||
|
style_set=req.style_set if structured_edit and req.mode == "edit" else (0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5),
|
||||||
request_payload={
|
request_payload={
|
||||||
"context": {"title": "AI 精修"},
|
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
|
||||||
"style_prompt": None,
|
"style_prompt": req.style_prompt if structured_edit else None,
|
||||||
|
"single_edit": {
|
||||||
|
"translate_text": req.translate_text,
|
||||||
|
"target_language": req.target_language,
|
||||||
|
"background": req.background,
|
||||||
|
"background_description": req.background_description,
|
||||||
|
} if structured_edit and req.mode == "edit" else None,
|
||||||
"requirements": req.prompt,
|
"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,
|
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||||
"append": bool(req.append and product_id),
|
"append": bool(req.append and product_id),
|
||||||
"after_asset_id": req.after_asset_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=[{
|
item_payloads=[{
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"kind": "custom",
|
"kind": "size_chart" if is_size_chart else "custom",
|
||||||
"title": "AI生图",
|
"title": "尺寸标注图" if is_size_chart else ("仅扩图" if req.mode == "outpaint" else "AI生图"),
|
||||||
"detail": "",
|
"detail": "",
|
||||||
"prompt_hint": "",
|
"prompt_hint": "",
|
||||||
"variant_name": None,
|
"variant_name": None,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class MaterialsRequest(BaseModel):
|
|||||||
texts: list[TextMaterial] = Field(default_factory=list)
|
texts: list[TextMaterial] = Field(default_factory=list)
|
||||||
images: list[ImageMaterial] = Field(default_factory=list)
|
images: list[ImageMaterial] = Field(default_factory=list)
|
||||||
refererOrigin: str | None = None # 下载源图时需带的 Referer
|
refererOrigin: str | None = None # 下载源图时需带的 Referer
|
||||||
|
process_assets: bool = True # 插件多批上报时,仅最后一批触发下载队列
|
||||||
|
|
||||||
|
|
||||||
class MaterialsResponse(BaseModel):
|
class MaterialsResponse(BaseModel):
|
||||||
|
|||||||
+32
-3
@@ -76,6 +76,15 @@ class WatermarkOptions(BaseModel):
|
|||||||
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
|
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductDimensions(BaseModel):
|
||||||
|
"""商品长宽高;尺寸图仅标注实际提供的方向。"""
|
||||||
|
|
||||||
|
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
|
||||||
|
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
|
||||||
|
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
|
||||||
|
unit: Literal["mm", "cm"] = Field(default="cm", description="尺寸单位")
|
||||||
|
|
||||||
|
|
||||||
class PlanItem(BaseModel):
|
class PlanItem(BaseModel):
|
||||||
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
||||||
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
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, ...}")
|
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
|
||||||
platform: str = Field(default="ozon")
|
platform: str = Field(default="ozon")
|
||||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
|
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
|
||||||
|
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于规划尺寸标注图")
|
||||||
|
|
||||||
|
|
||||||
class PlanItemOut(BaseModel):
|
class PlanItemOut(BaseModel):
|
||||||
@@ -129,6 +139,7 @@ class SuiteGenerateRequest(BaseModel):
|
|||||||
platform: str = Field(default="ozon")
|
platform: str = Field(default="ozon")
|
||||||
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
|
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
|
||||||
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
|
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
|
||||||
|
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
|
||||||
|
|
||||||
|
|
||||||
class SuiteCreateResponse(BaseModel):
|
class SuiteCreateResponse(BaseModel):
|
||||||
@@ -137,11 +148,13 @@ class SuiteCreateResponse(BaseModel):
|
|||||||
|
|
||||||
class SuiteImageOut(BaseModel):
|
class SuiteImageOut(BaseModel):
|
||||||
item_id: str
|
item_id: str
|
||||||
|
asset_id: str | None = None
|
||||||
type_id: str
|
type_id: str
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
status: str
|
status: str
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class SuiteOut(BaseModel):
|
class SuiteOut(BaseModel):
|
||||||
@@ -166,17 +179,33 @@ class SuiteOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ImageEditSingleRequest(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 组")
|
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
|
||||||
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
|
main_image_url: str | None = Field(default=None, description="本次编辑的主图;新界面必须显式传入")
|
||||||
|
reference_image_urls: list[str] = Field(default_factory=list, max_length=2, description="最多两张辅助参考图,按选择顺序传给模型")
|
||||||
|
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
|
||||||
|
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
|
||||||
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
|
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
|
||||||
model: str = Field(default="nano-banana-2")
|
mode: Literal["edit", "outpaint"] = Field(
|
||||||
|
default="edit",
|
||||||
|
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
|
||||||
|
)
|
||||||
|
model: str = Field(default="gpt-image-2@pcore")
|
||||||
|
ratio: Literal["3:4", "1:1", "4:5"] = Field(default="3:4", description="输出画幅")
|
||||||
|
target_language: Literal["ru", "ar", "en"] = Field(default="ru", description="图内文字的目标语言")
|
||||||
|
translate_text: bool = Field(default=False, description="在本次生图调用中识别、翻译并重新排版图中文字")
|
||||||
|
style_set: int = Field(default=2, ge=1, le=5)
|
||||||
|
style_prompt: str | None = Field(default=None, description="用户修改后的风格提示词")
|
||||||
|
background: Literal["auto", "white", "solid", "scene", "reference"] = Field(default="auto")
|
||||||
|
background_description: str | None = Field(default=None, description="纯色或场景背景的具体要求")
|
||||||
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
|
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
|
||||||
after_asset_id: str | None = Field(
|
after_asset_id: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
||||||
)
|
)
|
||||||
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
|
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):
|
class ImageEditTaskResponse(BaseModel):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
@@ -17,6 +18,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from services.prompts import build_context, build_prompt, type_name
|
from services.prompts import build_context, build_prompt, type_name
|
||||||
@@ -79,19 +81,21 @@ DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 +
|
|||||||
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
|
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
|
||||||
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
|
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
|
||||||
if provider == "doubao":
|
if provider == "doubao":
|
||||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
|
||||||
if provider == "rightapi":
|
if provider == "rightapi":
|
||||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
|
||||||
if provider == "pcore":
|
if provider == "pcore":
|
||||||
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
|
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
|
||||||
return "1024x1536" if ratio == "3:4" else "1024x1024"
|
return {"3:4": "1152x1536", "4:5": "1280x1600"}.get(ratio, "1024x1024")
|
||||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||||
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
|
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
|
||||||
if model.startswith("wan2.6"):
|
if model.startswith("wan2.6"):
|
||||||
return "1152*1536" if ratio == "3:4" else "1440*1440"
|
return {"3:4": "1152*1536", "4:5": "1280*1600"}.get(ratio, "1440*1440")
|
||||||
if ratio == "3:4":
|
if ratio == "3:4":
|
||||||
return "1536*2048" if is_wan else "768*1024"
|
return "1536*2048" if is_wan else "768*1024"
|
||||||
|
if ratio == "4:5":
|
||||||
|
return "1536*1920" if is_wan else "960*1200"
|
||||||
return "2048*2048" if is_wan else "1024*1024"
|
return "2048*2048" if is_wan else "1024*1024"
|
||||||
|
|
||||||
_DOUBAO_ANTI_AI = (
|
_DOUBAO_ANTI_AI = (
|
||||||
@@ -141,6 +145,75 @@ async def _resolve_ref(url: str) -> str:
|
|||||||
return _bytes_to_data_uri(data, mime)
|
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(火山方舟)────────────────────────────────────
|
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
|
||||||
|
|
||||||
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||||
@@ -449,7 +522,8 @@ async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model
|
|||||||
kind = "edits" if ref_images else "generations"
|
kind = "edits" if ref_images else "generations"
|
||||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||||
if ref_images:
|
if ref_images:
|
||||||
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
|
# Pcore 的 gpt-image-2 通道实际要求 OpenAI 兼容对象数组。
|
||||||
|
# 虽然通用文档描述字符串数组也可用,该通道会在异步任务中校验 images[].image_url。
|
||||||
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
|
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||||
@@ -562,6 +636,7 @@ async def advance_generation(
|
|||||||
model: str,
|
model: str,
|
||||||
provider_task_id: str | None = None,
|
provider_task_id: str | None = None,
|
||||||
provider_task_kind: str | None = None,
|
provider_task_kind: str | None = None,
|
||||||
|
edit_mask: str | None = None,
|
||||||
) -> GenerationAdvance:
|
) -> GenerationAdvance:
|
||||||
"""推进一次图片生成,不在本函数内无限轮询。
|
"""推进一次图片生成,不在本函数内无限轮询。
|
||||||
|
|
||||||
@@ -569,6 +644,8 @@ async def advance_generation(
|
|||||||
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
|
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
if edit_mask and provider != "pcore":
|
||||||
|
raise RuntimeError("带蒙版的仅扩图当前仅支持 Pcore gpt-image-2")
|
||||||
if provider == "doubao":
|
if provider == "doubao":
|
||||||
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model))
|
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model))
|
||||||
|
|
||||||
@@ -649,7 +726,12 @@ async def advance_generation(
|
|||||||
kind = "edits" if ref_images else "generations"
|
kind = "edits" if ref_images else "generations"
|
||||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||||
if ref_images:
|
if ref_images:
|
||||||
|
# 与 _pcore_request 保持同一经 gpt-image-2 实际验证的形状,避免 Worker 路径漂移。
|
||||||
body["images"] = [{"image_url": await _resolve_ref(item)} for item in 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(
|
response = await client.post(
|
||||||
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
|
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import logging
|
|||||||
import socket
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from db import get_session_factory
|
from db import get_session_factory
|
||||||
from models import ProductAsset
|
from models import ProductAsset
|
||||||
from services.image_assets import get_image_asset_service
|
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 (
|
from services.jobs import (
|
||||||
ClaimedItem,
|
ClaimedItem,
|
||||||
claim_next_item,
|
claim_next_item,
|
||||||
@@ -22,6 +24,8 @@ from services.jobs import (
|
|||||||
recover_expired_leases,
|
recover_expired_leases,
|
||||||
)
|
)
|
||||||
from services.prompts import build_context, build_prompt
|
from services.prompts import build_context, build_prompt
|
||||||
|
from services.prompts.common import requirements_block
|
||||||
|
from services.prompts.single_edit import build_single_edit_prompt
|
||||||
from services.storage import get_storage
|
from services.storage import get_storage
|
||||||
from services.suite_service import append_generated_asset
|
from services.suite_service import append_generated_asset
|
||||||
from services.tasks import TaskImage
|
from services.tasks import TaskImage
|
||||||
@@ -30,6 +34,10 @@ from services.video_renderer import VideoRenderCancelled, render_slideshow
|
|||||||
|
|
||||||
log = logging.getLogger("background.worker")
|
log = logging.getLogger("background.worker")
|
||||||
|
|
||||||
|
# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。
|
||||||
|
# 下一次轮询复用同一 task id,不会再次提交/计费。
|
||||||
|
PROVIDER_POLL_RETRY_DELAY_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
def _worker_owner() -> str:
|
def _worker_owner() -> str:
|
||||||
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
|
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
|
||||||
@@ -152,6 +160,22 @@ async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
|
|||||||
await mark_item_failed(claim.item_id, str(exc))
|
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:
|
async def process_item(claim: ClaimedItem) -> None:
|
||||||
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
|
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
|
||||||
if claim.job_kind == "watermark_batch":
|
if claim.job_kind == "watermark_batch":
|
||||||
@@ -174,18 +198,47 @@ async def process_item(claim: ClaimedItem) -> None:
|
|||||||
"pcore": "gpt-image-2",
|
"pcore": "gpt-image-2",
|
||||||
}.get(provider, settings.ark_image_model)
|
}.get(provider, settings.ark_image_model)
|
||||||
try:
|
try:
|
||||||
|
single_edit = payload.get("single_edit")
|
||||||
|
refs = (
|
||||||
|
[image["url"] for image in payload.get("ref_images") or []]
|
||||||
|
if single_edit else _refs_for_job(list(payload.get("ref_images") or []), job_item)
|
||||||
|
)
|
||||||
|
operation = payload.get("operation", "edit")
|
||||||
|
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
|
||||||
|
edit_mask: str | None = None
|
||||||
|
outpaint: OutpaintCanvas | None = None
|
||||||
|
if operation == "outpaint":
|
||||||
|
if len(refs) != 1:
|
||||||
|
raise RuntimeError("仅扩图必须且只能使用一张参考底图")
|
||||||
|
prompt = _outpaint_prompt(payload.get("requirements"))
|
||||||
|
# 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。
|
||||||
|
if not claim.provider_task_id:
|
||||||
|
outpaint = await prepare_outpaint_canvas(refs[0], target_size)
|
||||||
|
refs = [outpaint.image_data_url]
|
||||||
|
edit_mask = outpaint.mask_data_url
|
||||||
|
else:
|
||||||
context = build_context(payload.get("context") or {}, fallback_name="")
|
context = build_context(payload.get("context") or {}, fallback_name="")
|
||||||
|
if single_edit:
|
||||||
|
prompt = build_single_edit_prompt(
|
||||||
|
requirements=payload.get("requirements") or "", config=single_edit,
|
||||||
|
context=context, style_set=claim.style_set,
|
||||||
|
style_prompt=payload.get("style_prompt"), ratio=claim.ratio,
|
||||||
|
is_size_chart=job_item.get("kind") == "size_chart",
|
||||||
|
reference_count=len(refs) - 1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
prompt_extra = job_item.get("prompt_extra") or job_item
|
prompt_extra = job_item.get("prompt_extra") or job_item
|
||||||
prompt = build_prompt(
|
prompt = build_prompt(
|
||||||
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
|
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"),
|
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")
|
is_wan = provider == "tongyi" and model.lower().startswith("wan")
|
||||||
result = await advance_generation(
|
result = await advance_generation(
|
||||||
provider, prompt, refs,
|
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,
|
provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
|
||||||
|
edit_mask=edit_mask,
|
||||||
)
|
)
|
||||||
if result.data is None:
|
if result.data is None:
|
||||||
if not result.provider_task_id or not result.provider_task_kind:
|
if not result.provider_task_id or not result.provider_task_kind:
|
||||||
@@ -203,6 +256,11 @@ async def process_item(claim: ClaimedItem) -> None:
|
|||||||
await mark_item_cancelled(claim.item_id)
|
await mark_item_cancelled(claim.item_id)
|
||||||
return
|
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 {}
|
watermark = payload.get("watermark") or {}
|
||||||
position = int(job_item.get("position", 0))
|
position = int(job_item.get("position", 0))
|
||||||
if watermark.get("enabled"):
|
if watermark.get("enabled"):
|
||||||
@@ -236,6 +294,21 @@ async def process_item(claim: ClaimedItem) -> None:
|
|||||||
claim.item_id, result_url=result_url, source_url=source_url or None,
|
claim.item_id, result_url=result_url, source_url=source_url or None,
|
||||||
watermark_config=watermark_config, generated_asset_id=asset_id,
|
watermark_config=watermark_config, generated_asset_id=asset_id,
|
||||||
)
|
)
|
||||||
|
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||||
|
if claim.provider_task_id and claim.provider_task_kind:
|
||||||
|
log.warning(
|
||||||
|
"供应商轮询网络暂时失败,%ds 后重试同一任务 %s:%s",
|
||||||
|
PROVIDER_POLL_RETRY_DELAY_SECONDS, claim.provider_task_id, exc.__class__.__name__,
|
||||||
|
)
|
||||||
|
await mark_waiting_provider(
|
||||||
|
claim.item_id,
|
||||||
|
claim.provider_task_id,
|
||||||
|
claim.provider_task_kind,
|
||||||
|
delay_seconds=PROVIDER_POLL_RETRY_DELAY_SECONDS,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
log.exception("后台图片任务 %s/%s 网络请求失败", claim.job_id, claim.item_id)
|
||||||
|
await mark_item_failed(claim.item_id, str(exc) or exc.__class__.__name__)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
||||||
await mark_item_failed(claim.item_id, str(exc))
|
await mark_item_failed(claim.item_id, str(exc))
|
||||||
|
|||||||
@@ -489,11 +489,12 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
|
|||||||
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
|
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
|
"asset_id": str(item.generated_asset_id) if item.generated_asset_id else None,
|
||||||
"type_id": (item.payload or {}).get("kind", "custom"),
|
"type_id": (item.payload or {}).get("kind", "custom"),
|
||||||
"item_id": str(item.id),
|
"item_id": str(item.id),
|
||||||
"name": (item.payload or {}).get("title", "AI 生图"),
|
"name": (item.payload or {}).get("title", "AI 生图"),
|
||||||
"url": item.result_url or "", "status": item.status,
|
"url": item.result_url or "", "status": item.status,
|
||||||
"error": item.error,
|
"error": item.error, "completed_at": item.completed_at,
|
||||||
}
|
}
|
||||||
for item in items
|
for item in items
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ ALLOWED_KINDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
|
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
|
||||||
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
|
# AI 智能规划最多生成 8 张;即使用户在要求中指定更高张数也不突破此上限。
|
||||||
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
|
PLAN_SAFETY_MAX = 8
|
||||||
PLAN_SAFETY_MAX = 15
|
PLAN_REQUIREMENTS_MAX = 8
|
||||||
PLAN_REQUIREMENTS_MAX = 24
|
|
||||||
MAX_VARIANT_MAIN_IMAGES = 2
|
MAX_VARIANT_MAIN_IMAGES = 2
|
||||||
|
|
||||||
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
|
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
|
||||||
@@ -53,15 +52,15 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
|
|||||||
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
||||||
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
||||||
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
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。
|
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
||||||
|
|
||||||
## 规划规则
|
## 规划规则
|
||||||
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
|
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的任一方向尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
|
||||||
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
|
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
|
||||||
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
|
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
|
||||||
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
|
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
|
||||||
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。
|
5. 尺寸标注图(kind=size_chart):参数含任一可靠尺寸时必须出 1 张;只标注已知方向,无可靠尺寸参数时不要虚构尺寸。
|
||||||
6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 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。
|
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
|
||||||
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
||||||
@@ -125,7 +124,10 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _has_dimensions(product_info: dict) -> bool:
|
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 []:
|
for param in product_info.get("params") or []:
|
||||||
if not isinstance(param, dict):
|
if not isinstance(param, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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,
|
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,
|
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。"""
|
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
|
||||||
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
|
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
|
||||||
return module.build_prompt(
|
return module.build_prompt(
|
||||||
type_id, ctx, style_set, lang,
|
type_id, ctx, style_set, lang,
|
||||||
extra=extra, style_prompt=style_prompt, requirements=requirements,
|
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:
|
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
|
||||||
dims = params_line_for_output(ctx, lang)
|
dims = params_line_for_output(ctx, lang)
|
||||||
|
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
|
||||||
return (
|
return (
|
||||||
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
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"measurement values rendered next to each line"
|
||||||
+ (f" (known specs: {dims})" if dims else "")
|
+ (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}"
|
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,
|
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(主体参考语义)。
|
"""构造指定图类型的完整生图 prompt(主体参考语义)。
|
||||||
|
|
||||||
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
|
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 最前面,
|
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)
|
style = resolve_style(style_set, style_prompt)
|
||||||
extra = extra or {}
|
extra = extra or {}
|
||||||
if type_id == "custom":
|
if type_id == "custom":
|
||||||
|
|||||||
@@ -12,27 +12,27 @@ import re
|
|||||||
STYLE_SETS: dict[int, dict] = {
|
STYLE_SETS: dict[int, dict] = {
|
||||||
1: {
|
1: {
|
||||||
"name": "北欧极简",
|
"name": "北欧极简",
|
||||||
"tone": "Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition",
|
"tone": "Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
"name": "清新明亮",
|
"name": "清新明亮",
|
||||||
"tone": "Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition",
|
"tone": "Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
"name": "高级感深色",
|
"name": "高级感深色",
|
||||||
"tone": "Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood",
|
"tone": "Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
"name": "暖调生活",
|
"name": "暖调生活",
|
||||||
"tone": "Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere",
|
"tone": "Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
5: {
|
5: {
|
||||||
"name": "纯净棚拍",
|
"name": "纯净棚拍",
|
||||||
"tone": "Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent",
|
"tone": "Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -133,6 +133,29 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
|
|||||||
if len(selling_points) >= 5:
|
if len(selling_points) >= 5:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
dimensions = raw.get("dimensions") or {}
|
||||||
|
known_dimensions: list[tuple[str, object]] = []
|
||||||
|
if isinstance(dimensions, dict):
|
||||||
|
known_dimensions = [
|
||||||
|
(label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
|
||||||
|
if dimensions.get(key) is not None
|
||||||
|
]
|
||||||
|
unit = (dimensions.get("unit") or "cm") if isinstance(dimensions, dict) else "cm"
|
||||||
|
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
|
||||||
|
known_labels = [label for label, _ in known_dimensions]
|
||||||
|
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
|
||||||
|
dimension_annotation_instruction = (
|
||||||
|
"STRICT MEASUREMENT OUTPUT RULE: render exactly "
|
||||||
|
+ str(len(known_labels))
|
||||||
|
+ " measurement arrows and labels, only for: "
|
||||||
|
+ ", ".join(known_labels)
|
||||||
|
+ ". The only permitted dimension values are: "
|
||||||
|
+ dimensions_line
|
||||||
|
+ ". Do not infer product measurements. Do not draw, label, or show an arrow for: "
|
||||||
|
+ ", ".join(missing_labels or ["any additional direction"])
|
||||||
|
+ ", depth, thickness, diameter, or any other unlisted dimension."
|
||||||
|
if known_dimensions else ""
|
||||||
|
)
|
||||||
params_line = "; ".join(
|
params_line = "; ".join(
|
||||||
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
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, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
||||||
"desc": desc,
|
"desc": desc,
|
||||||
"selling_points": selling_points[:3],
|
"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')}"
|
f"{p.get('key')}: {p.get('value')}"
|
||||||
for p in (raw.get("params") or [])[:8]
|
for p in (raw.get("params") or [])[:8]
|
||||||
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
|
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
|
||||||
),
|
),
|
||||||
|
dimensions_line,
|
||||||
|
))),
|
||||||
"price": raw.get("price") or "",
|
"price": raw.get("price") or "",
|
||||||
|
"dimension_annotation_instruction": dimension_annotation_instruction,
|
||||||
}
|
}
|
||||||
|
|
||||||
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
|
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:
|
def _task_size_chart(ctx: dict, lang: str) -> str:
|
||||||
dims = params_line_for_output(ctx, lang)
|
dims = params_line_for_output(ctx, lang)
|
||||||
|
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
|
||||||
return (
|
return (
|
||||||
"Create a size chart: the unchanged product in clean front and side views on a light background, "
|
"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"
|
"each line"
|
||||||
+ (f" (known specs: {dims})" if dims else "")
|
+ (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:
|
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,
|
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:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。"""
|
"""构造指定图类型的 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)
|
style = resolve_style(style_set, style_prompt)
|
||||||
extra = extra or {}
|
extra = extra or {}
|
||||||
hint = (extra.get("prompt_hint") or "").strip()
|
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:
|
def _task_size_chart(ctx: dict, lang: str) -> str:
|
||||||
dims = params_line_for_output(ctx, lang)
|
dims = params_line_for_output(ctx, lang)
|
||||||
|
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
|
||||||
return (
|
return (
|
||||||
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
|
"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 "
|
"background; overlay thin technical annotation lines (arrows) only for the supplied dimensions, "
|
||||||
"with measurement values rendered beside each line"
|
"with each measurement value rendered beside its line"
|
||||||
+ (f" (known specs: {dims})" if dims else "")
|
+ (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:
|
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,
|
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:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。"""
|
"""构造指定图类型的 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)
|
style = resolve_style(style_set, style_prompt)
|
||||||
extra = extra or {}
|
extra = extra or {}
|
||||||
hint = (extra.get("prompt_hint") or "").strip()
|
hint = (extra.get("prompt_hint") or "").strip()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""单张 AI 编辑提示词:一次图像模型调用完成编辑、翻译和排版。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.prompts.common import requirements_block, resolve_style
|
||||||
|
|
||||||
|
|
||||||
|
LANGUAGES = {"ru": "Russian", "ar": "Arabic", "en": "English"}
|
||||||
|
|
||||||
|
BACKGROUNDS = {
|
||||||
|
"auto": "Keep the original background unless the user's requirements ask to change it.",
|
||||||
|
"white": "Replace only the background with clean pure white; retain natural contact shadows.",
|
||||||
|
"solid": "Replace only the background with a clean solid color as described below.",
|
||||||
|
"scene": "Replace only the background with the described realistic scene. Keep the product as the hero.",
|
||||||
|
"reference": "Use Image 2 for background and lighting direction. Do not copy its product.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_single_edit_prompt(
|
||||||
|
*, requirements: str, config: dict, context: dict, style_set: int,
|
||||||
|
style_prompt: str | None, ratio: str, is_size_chart: bool, reference_count: int,
|
||||||
|
) -> str:
|
||||||
|
"""Image 1 always supplies the product; following images are supporting references only."""
|
||||||
|
language = LANGUAGES.get(config.get("target_language"), "Russian")
|
||||||
|
background = config.get("background") or "auto"
|
||||||
|
sections = [
|
||||||
|
requirements_block(requirements),
|
||||||
|
"Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. "
|
||||||
|
"Preserve the product's shape, proportions, color, material, logo and distinguishing details. "
|
||||||
|
"Do not replace it with products from any other image.",
|
||||||
|
]
|
||||||
|
if reference_count:
|
||||||
|
sections.append(
|
||||||
|
f"Images 2 through {reference_count + 1} are auxiliary references only. "
|
||||||
|
"Use them for product detail, material, scene or lighting as appropriate; "
|
||||||
|
"Image 1 remains the source of truth for the product."
|
||||||
|
)
|
||||||
|
sections.append("BACKGROUND: " + BACKGROUNDS.get(background, BACKGROUNDS["auto"]))
|
||||||
|
if config.get("background_description"):
|
||||||
|
sections.append("Background details: " + str(config["background_description"]).strip())
|
||||||
|
if config.get("translate_text"):
|
||||||
|
sections.append(
|
||||||
|
f"In this same image-generation pass, read the visible marketing text in Image 1, "
|
||||||
|
f"translate its meaning into {language}, replace the source-language marketing copy, "
|
||||||
|
"and redesign the text layout for a polished, legible composition. "
|
||||||
|
"For Arabic, use correct right-to-left typesetting. Keep factual values and units exact. "
|
||||||
|
"Preserve text printed on the physical product and its branding unless the user explicitly asks to edit them. "
|
||||||
|
"Do not add unsupported claims."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sections.append(
|
||||||
|
"Do not invent marketing copy or translate existing image text. "
|
||||||
|
"Preserve text printed on the physical product unless explicitly requested otherwise."
|
||||||
|
)
|
||||||
|
if is_size_chart:
|
||||||
|
annotation = context.get("dimension_annotation_instruction")
|
||||||
|
if annotation:
|
||||||
|
sections.append(
|
||||||
|
"Create a clean product measurement diagram with legible dimension arrows. "
|
||||||
|
+ str(annotation)
|
||||||
|
)
|
||||||
|
style = resolve_style(style_set, style_prompt)
|
||||||
|
sections.append("VISUAL STYLE: " + str(style["tone"]))
|
||||||
|
sections.append(
|
||||||
|
f"OUTPUT: exactly {ratio} portrait or square composition as specified. "
|
||||||
|
"Apply the user's explicit background and text instructions before style preferences. "
|
||||||
|
"Do not add a watermark or extra panels."
|
||||||
|
)
|
||||||
|
return "\n\n".join(part for part in sections if part)
|
||||||
@@ -58,11 +58,16 @@ async def _run_process(
|
|||||||
should_cancel: Callable[[], Awaitable[bool]],
|
should_cancel: Callable[[], Awaitable[bool]],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise VideoRenderError(
|
||||||
|
f"未找到视频合成工具「{args[0]}」。请安装 FFmpeg,并配置 VIDEO_FFMPEG_PATH 为其可执行文件的绝对路径。"
|
||||||
|
) from exc
|
||||||
try:
|
try:
|
||||||
waited = 0.0
|
waited = 0.0
|
||||||
while process.returncode is None:
|
while process.returncode is None:
|
||||||
@@ -91,6 +96,7 @@ async def _run_process(
|
|||||||
|
|
||||||
async def _probe(path: Path) -> dict:
|
async def _probe(path: Path) -> dict:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
settings.video_ffprobe_path,
|
settings.video_ffprobe_path,
|
||||||
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
|
"-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,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=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()
|
stdout, stderr = await process.communicate()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
|
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
"""单图导出响应头回归测试。"""
|
"""单图导出响应头回归测试。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
if str(SERVER_DIR) not in sys.path:
|
if str(SERVER_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(SERVER_DIR))
|
sys.path.insert(0, str(SERVER_DIR))
|
||||||
|
|
||||||
from api.export import ExportImageItem, export_image
|
from api.export import ExportImageItem, ExportImagesRequest, export_image, export_images
|
||||||
|
|
||||||
|
|
||||||
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
|
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_extensionless_image_with_generic_mime_can_be_downloaded(self) -> None:
|
||||||
|
image = ExportImageItem(url="https://example.com/assets/opaque-key", key="upload")
|
||||||
|
png = b"\x89PNG\r\n\x1a\n" + b"image bytes"
|
||||||
|
with patch("api.export._download", new=AsyncMock(return_value=(png, "application/octet-stream"))):
|
||||||
|
response = await export_image(image)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.media_type, "image/png")
|
||||||
|
self.assertIn('filename="upload.png"', response.headers["content-disposition"])
|
||||||
|
|
||||||
|
async def test_extensionless_non_image_is_rejected(self) -> None:
|
||||||
|
image = ExportImageItem(url="https://example.com/assets/opaque-key")
|
||||||
|
with patch("api.export._download", new=AsyncMock(return_value=(b"not an image", "application/octet-stream"))):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
await export_image(image)
|
||||||
|
self.assertEqual(raised.exception.status_code, 400)
|
||||||
|
|
||||||
async def test_chinese_filename_uses_rfc5987_header(self) -> None:
|
async def test_chinese_filename_uses_rfc5987_header(self) -> None:
|
||||||
image = ExportImageItem(
|
image = ExportImageItem(
|
||||||
url="https://example.com/image.png",
|
url="https://example.com/image.png",
|
||||||
@@ -27,6 +48,24 @@ class ExportImageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn('filename="_____-__.png"', 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)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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()
|
||||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
if str(SERVER_DIR) not in sys.path:
|
if str(SERVER_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(SERVER_DIR))
|
sys.path.insert(0, str(SERVER_DIR))
|
||||||
@@ -58,6 +60,46 @@ class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
cancelled.assert_awaited_once_with(claim.item_id)
|
cancelled.assert_awaited_once_with(claim.item_id)
|
||||||
waiting.assert_not_awaited()
|
waiting.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_provider_poll_timeout_keeps_existing_task_for_retry(self) -> None:
|
||||||
|
claim = ClaimedItem(
|
||||||
|
job_id="00000000-0000-0000-0000-000000000001",
|
||||||
|
item_id="00000000-0000-0000-0000-000000000002",
|
||||||
|
job_kind="image_edit",
|
||||||
|
product_id=None,
|
||||||
|
provider="pcore",
|
||||||
|
model="gpt-image-2",
|
||||||
|
platform="ozon",
|
||||||
|
lang="ru",
|
||||||
|
ratio="3:4",
|
||||||
|
style_set=5,
|
||||||
|
request_payload={"context": {"title": "test"}, "ref_images": []},
|
||||||
|
item_payload={"kind": "custom", "title": "AI生图"},
|
||||||
|
provider_task_id="provider-task",
|
||||||
|
provider_task_kind="edits",
|
||||||
|
)
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
image_provider="pcore",
|
||||||
|
dashscope_model="wan2.7-image-pro",
|
||||||
|
rightapi_image_model="gpt-image-2",
|
||||||
|
ark_image_model="doubao-seedream-4-5-251128",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("services.job_worker.get_settings", return_value=settings),
|
||||||
|
patch("services.job_worker.build_context", return_value={}),
|
||||||
|
patch("services.job_worker.build_prompt", return_value="prompt"),
|
||||||
|
patch("services.job_worker._refs_for_job", return_value=[]),
|
||||||
|
patch("services.job_worker.advance_generation", new=AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))),
|
||||||
|
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
|
||||||
|
patch("services.job_worker.mark_item_failed", new=AsyncMock()) as failed,
|
||||||
|
):
|
||||||
|
await process_item(claim)
|
||||||
|
|
||||||
|
waiting.assert_awaited_once_with(
|
||||||
|
claim.item_id, "provider-task", "edits", delay_seconds=30,
|
||||||
|
)
|
||||||
|
failed.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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,81 @@
|
|||||||
|
"""单张编辑的新参数必须进入同一次生图任务,且主图顺序与尺寸准确。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SERVER_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SERVER_DIR))
|
||||||
|
|
||||||
|
from api.suite import suite_image_edit, _with_dimensions
|
||||||
|
from schemas.suite import ImageEditSingleRequest, ProductDimensions
|
||||||
|
from services.generator import _image_size
|
||||||
|
from services.prompts.common import build_context
|
||||||
|
from services.prompts.single_edit import build_single_edit_prompt
|
||||||
|
|
||||||
|
|
||||||
|
class SingleEditRequestTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_main_and_two_auxiliary_images_keep_order_in_one_item(self) -> None:
|
||||||
|
request = ImageEditSingleRequest(
|
||||||
|
main_image_url="main", reference_image_urls=["detail", "scene"],
|
||||||
|
prompt="替换背景", model="gpt-image-2@pcore", translate_text=True,
|
||||||
|
target_language="ar", background="reference", ratio="4:5",
|
||||||
|
)
|
||||||
|
job = AsyncMock(return_value=SimpleNamespace(id="task-id"))
|
||||||
|
with patch("api.suite.create_image_job", job):
|
||||||
|
response = await suite_image_edit(request)
|
||||||
|
self.assertEqual(response.suite_id, "task-id")
|
||||||
|
payload = job.await_args.kwargs
|
||||||
|
self.assertEqual(payload["ratio"], "4:5")
|
||||||
|
self.assertEqual(payload["lang"], "ar")
|
||||||
|
self.assertEqual(payload["provider"], "pcore")
|
||||||
|
self.assertEqual(payload["model"], "gpt-image-2")
|
||||||
|
self.assertEqual(len(payload["item_payloads"]), 1)
|
||||||
|
self.assertEqual([image["url"] for image in payload["request_payload"]["ref_images"]], ["main", "detail", "scene"])
|
||||||
|
self.assertTrue(payload["request_payload"]["single_edit"]["translate_text"])
|
||||||
|
|
||||||
|
async def test_structured_edit_rejects_other_provider(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
await suite_image_edit(ImageEditSingleRequest(
|
||||||
|
main_image_url="main", model="nano-banana-2",
|
||||||
|
))
|
||||||
|
self.assertEqual(raised.exception.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class SingleEditPromptTests(unittest.TestCase):
|
||||||
|
def test_translation_background_and_partial_dimensions_share_one_prompt(self) -> None:
|
||||||
|
context = build_context(_with_dimensions(
|
||||||
|
{"title": "AI 精修"}, ProductDimensions(width=8, unit="cm"), required=True,
|
||||||
|
))
|
||||||
|
prompt = build_single_edit_prompt(
|
||||||
|
requirements="保留商品颜色", config={"translate_text": True, "target_language": "ar", "background": "scene", "background_description": "warm kitchen"},
|
||||||
|
context=context, style_set=2, style_prompt=None, ratio="3:4", is_size_chart=True, reference_count=2,
|
||||||
|
)
|
||||||
|
self.assertIn("Image 1 is the fixed main product image", prompt)
|
||||||
|
self.assertIn("Images 2 through 3 are auxiliary", prompt)
|
||||||
|
self.assertIn("Arabic", prompt)
|
||||||
|
self.assertIn("right-to-left", prompt)
|
||||||
|
self.assertIn("warm kitchen", prompt)
|
||||||
|
self.assertIn("width: 8.0 cm", prompt)
|
||||||
|
self.assertIn("Do not draw, label, or show an arrow for: length, height", prompt)
|
||||||
|
|
||||||
|
def test_four_to_five_size_does_not_fall_back_to_square(self) -> None:
|
||||||
|
for provider, is_wan, model in (
|
||||||
|
("pcore", True, "gpt-image-2"),
|
||||||
|
("rightapi", True, "gpt-image-2"),
|
||||||
|
("doubao", True, "seedream"),
|
||||||
|
("tongyi", True, "wan2.6-image"),
|
||||||
|
("tongyi", False, "qwen-image-3.0-pro"),
|
||||||
|
):
|
||||||
|
with self.subTest(provider=provider, model=model):
|
||||||
|
width, height = map(int, _image_size(provider, "4:5", is_wan, model).replace("*", "x").split("x"))
|
||||||
|
self.assertEqual(width * 5, height * 4)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+38
-9
@@ -9,14 +9,23 @@ BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}"
|
|||||||
STUDIO_URL="http://localhost:8900"
|
STUDIO_URL="http://localhost:8900"
|
||||||
RUNTIME_DIR="$PROJECT_DIR/data/runtime"
|
RUNTIME_DIR="$PROJECT_DIR/data/runtime"
|
||||||
INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid"
|
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 树结束,
|
# 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束,
|
||||||
# 不扫描或误杀其他项目的 Python / Node 进程。
|
# 不扫描或误杀其他项目的 Python / Node 进程。
|
||||||
BACKEND_PID=""
|
BACKEND_PID=""
|
||||||
STUDIO_PID=""
|
STUDIO_PID=""
|
||||||
WORKER_SUPERVISOR_PID=""
|
WORKER_SUPERVISOR_PID=""
|
||||||
|
WORKER_SOURCE_WATCH_PID=""
|
||||||
COLLECTOR_WATCH_PID=""
|
COLLECTOR_WATCH_PID=""
|
||||||
COLLECTOR_STAMP=""
|
COLLECTOR_STAMP=""
|
||||||
|
WORKER_SOURCE_STAMP=""
|
||||||
CLEANUP_DONE=false
|
CLEANUP_DONE=false
|
||||||
|
|
||||||
stop_process_tree() {
|
stop_process_tree() {
|
||||||
@@ -37,8 +46,10 @@ cleanup() {
|
|||||||
stop_process_tree "$BACKEND_PID"
|
stop_process_tree "$BACKEND_PID"
|
||||||
stop_process_tree "$STUDIO_PID"
|
stop_process_tree "$STUDIO_PID"
|
||||||
stop_process_tree "$WORKER_SUPERVISOR_PID"
|
stop_process_tree "$WORKER_SUPERVISOR_PID"
|
||||||
|
stop_process_tree "$WORKER_SOURCE_WATCH_PID"
|
||||||
stop_process_tree "$COLLECTOR_WATCH_PID"
|
stop_process_tree "$COLLECTOR_WATCH_PID"
|
||||||
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null
|
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null
|
||||||
|
[[ -n "$WORKER_SOURCE_STAMP" ]] && rm -f "$WORKER_SOURCE_STAMP" 2>/dev/null
|
||||||
if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then
|
if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then
|
||||||
rm -f "$INSTANCE_PID_FILE"
|
rm -f "$INSTANCE_PID_FILE"
|
||||||
fi
|
fi
|
||||||
@@ -141,7 +152,7 @@ watch_collector() {
|
|||||||
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
|
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
|
||||||
touch "$COLLECTOR_STAMP"
|
touch "$COLLECTOR_STAMP"
|
||||||
echo "🔧 检测到插件代码改动,重新构建…"
|
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 点刷新生效"
|
echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效"
|
||||||
else
|
else
|
||||||
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
|
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
|
||||||
@@ -152,13 +163,13 @@ watch_collector() {
|
|||||||
|
|
||||||
if [[ ! -d extensions/collector ]]; then
|
if [[ ! -d extensions/collector ]]; then
|
||||||
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
|
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
|
||||||
elif ! command -v pnpm >/dev/null 2>&1; then
|
elif ! command -v corepack >/dev/null 2>&1; then
|
||||||
echo "⚠️ 未找到 pnpm,本次跳过采集插件构建(可手动:pnpm -C extensions/collector build)"
|
echo "⚠️ 未找到 Corepack,本次跳过采集插件构建(可手动:corepack pnpm@${PNPM_VERSION} -C extensions/collector build)"
|
||||||
elif ! CI=true pnpm -C extensions/collector install --silent; then
|
elif ! CI=true project_pnpm -C extensions/collector install --silent; then
|
||||||
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
|
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
|
||||||
else
|
else
|
||||||
echo "正在构建采集插件…"
|
echo "正在构建采集插件…"
|
||||||
if pnpm -C extensions/collector build; then
|
if project_pnpm -C extensions/collector build; then
|
||||||
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
|
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
|
||||||
else
|
else
|
||||||
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
|
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
|
||||||
@@ -182,11 +193,29 @@ supervise_worker &
|
|||||||
WORKER_SUPERVISOR_PID=$!
|
WORKER_SUPERVISOR_PID=$!
|
||||||
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
|
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
|
||||||
|
|
||||||
if ! command -v pnpm >/dev/null 2>&1; then
|
# Uvicorn 的热更新只管理 Web 进程;Worker 独立运行,代码更新后必须主动重启,
|
||||||
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
|
# 否则它会继续用内存中已导入的旧 generator.py 提交模型任务。
|
||||||
|
WORKER_SOURCE_STAMP="$(mktemp /tmp/osk-worker-source.XXXXXX)"
|
||||||
|
watch_worker_source() {
|
||||||
|
while true; do
|
||||||
|
sleep 2
|
||||||
|
if [[ -n "$(find server -type f \( -name '*.py' -o -name '*.yaml' \) -newer "$WORKER_SOURCE_STAMP" -print -quit 2>/dev/null)" ]]; then
|
||||||
|
touch "$WORKER_SOURCE_STAMP"
|
||||||
|
echo "🔧 检测到 Worker 源码改动,正在重启 Worker 以应用新版本…"
|
||||||
|
for worker_pid in $(pgrep -P "$WORKER_SUPERVISOR_PID" 2>/dev/null); do
|
||||||
|
kill -TERM "$worker_pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
watch_worker_source &
|
||||||
|
WORKER_SOURCE_WATCH_PID=$!
|
||||||
|
|
||||||
|
if ! command -v corepack >/dev/null 2>&1; then
|
||||||
|
echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)"
|
||||||
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
|
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
|
||||||
elif CI=true pnpm -C studio install --silent; then
|
elif CI=true project_pnpm -C studio install --silent; then
|
||||||
pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器
|
project_pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器
|
||||||
STUDIO_PID=$!
|
STUDIO_PID=$!
|
||||||
else
|
else
|
||||||
echo "⚠️ studio 依赖安装失败,本次只启动后端"
|
echo "⚠️ studio 依赖安装失败,本次只启动后端"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface Props {
|
|||||||
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
|
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
|
||||||
const [models, setModels] = useState<AiModelOption[]>([]);
|
const [models, setModels] = useState<AiModelOption[]>([]);
|
||||||
const [model, setModel] = useState<string>('');
|
const [model, setModel] = useState<string>('');
|
||||||
const [sourceText, setSourceText] = useState('');
|
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [result, setResult] = useState<CopyResponse | null>(null);
|
const [result, setResult] = useState<CopyResponse | null>(null);
|
||||||
|
|
||||||
@@ -41,8 +40,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
|
|||||||
return parts.join('\n\n');
|
return parts.join('\n\n');
|
||||||
}, [raw]);
|
}, [raw]);
|
||||||
|
|
||||||
|
// 文案模块的“采集信息”是可人工整理的 AI 输入。优先恢复用户上次保存的内容;
|
||||||
|
// 只有从未编辑过时才从当前采集数据自动拼出默认值。
|
||||||
|
const savedSourceText = typeof raw.copy_source_text === 'string' ? raw.copy_source_text : null;
|
||||||
|
const [sourceText, setSourceText] = useState(() => savedSourceText ?? defaultSource);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceText && defaultSource) setSourceText(defaultSource);
|
|
||||||
getAiModels()
|
getAiModels()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setModels(r.models);
|
setModels(r.models);
|
||||||
@@ -52,6 +55,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** 与价格试算一致:用户完成输入(失焦)后将编辑后的 AI 输入落到当前商品。 */
|
||||||
|
const saveSourceText = async () => {
|
||||||
|
if (sourceText === savedSourceText) return;
|
||||||
|
await onSave({ raw: { ...raw, copy_source_text: sourceText } });
|
||||||
|
};
|
||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
if (sourceText.trim().length < 10) {
|
if (sourceText.trim().length < 10) {
|
||||||
message.warning('请先粘贴至少 10 字的商品资料');
|
message.warning('请先粘贴至少 10 字的商品资料');
|
||||||
@@ -115,8 +124,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
|
|||||||
rows={16}
|
rows={16}
|
||||||
value={sourceText}
|
value={sourceText}
|
||||||
onChange={(e) => setSourceText(e.target.value)}
|
onChange={(e) => setSourceText(e.target.value)}
|
||||||
|
onBlur={() => { void saveSourceText(); }}
|
||||||
placeholder="采集的商品资料(可编辑后生成)"
|
placeholder="采集的商品资料(可编辑后生成)"
|
||||||
/>
|
/>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 12 }}>
|
||||||
|
修改后失去焦点会自动保存
|
||||||
|
</Text>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd';
|
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 { getFxRate } from '@/services/fx';
|
||||||
import { ProductDetail } from '@/services/product';
|
import { ProductDetail } from '@/services/product';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
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) {
|
export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||||
const pricing = (product.pricing ?? {}) as PricingPayload;
|
const pricing = (product.pricing ?? {}) as PricingPayload;
|
||||||
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
|
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
|
||||||
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
|
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
|
||||||
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
|
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 [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||||
|
|
||||||
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
|
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
|
||||||
@@ -67,7 +69,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
|||||||
const tdPrice = pricing.tdPrice ?? 3;
|
const tdPrice = pricing.tdPrice ?? 3;
|
||||||
|
|
||||||
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
|
/** 用当前参数(可被 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({
|
calculatePricing({
|
||||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||||
profitRate: patch.profitRate ?? profitRate,
|
profitRate: patch.profitRate ?? profitRate,
|
||||||
@@ -75,17 +77,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
|||||||
weightG: weightGrams(product),
|
weightG: weightGrams(product),
|
||||||
dims: dimsCm(product),
|
dims: dimsCm(product),
|
||||||
tdPrice,
|
tdPrice,
|
||||||
lineMultiplier: patch.multiplier ?? multiplier,
|
lineDiscountRate: patch.discountRate ?? discountRate,
|
||||||
fxRate: patch.fxRate ?? fxRate,
|
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 = {
|
const p = {
|
||||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||||
profitRate: patch.profitRate ?? profitRate,
|
profitRate: patch.profitRate ?? profitRate,
|
||||||
level: patch.level ?? level,
|
level: patch.level ?? level,
|
||||||
multiplier: patch.multiplier ?? multiplier,
|
discountRate: patch.discountRate ?? discountRate,
|
||||||
fxRate: patch.fxRate ?? fxRate,
|
fxRate: patch.fxRate ?? fxRate,
|
||||||
};
|
};
|
||||||
const r = compute(patch);
|
const r = compute(patch);
|
||||||
@@ -98,7 +100,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
|||||||
weightG: weightGrams(product),
|
weightG: weightGrams(product),
|
||||||
dims: dimsCm(product),
|
dims: dimsCm(product),
|
||||||
tdPrice,
|
tdPrice,
|
||||||
lineMultiplier: p.multiplier,
|
lineDiscountRate: p.discountRate,
|
||||||
fxRate: p.fxRate,
|
fxRate: p.fxRate,
|
||||||
logisticsFee: r.logisticsFee,
|
logisticsFee: r.logisticsFee,
|
||||||
fullCommission: r.fullCommission,
|
fullCommission: r.fullCommission,
|
||||||
@@ -189,15 +191,16 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col span={5}>
|
||||||
<FieldLabel>划线价倍数 %</FieldLabel>
|
<FieldLabel>划线折扣率 %</FieldLabel>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
max={500}
|
max={99.99}
|
||||||
value={multiplier}
|
precision={2}
|
||||||
|
value={discountRate}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setMultiplier(v ?? 0);
|
setDiscountRate(v ?? 0);
|
||||||
recalc({ multiplier: v ?? 0 });
|
recalc({ discountRate: v ?? 0 });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.single-ai-drawer .ant-drawer-body { padding: 18px 22px; background: #f8f9fb; }
|
||||||
|
.single-ai-drawer .ant-drawer-footer { padding: 12px 22px; }
|
||||||
|
.single-ai-title { display: flex; gap: 12px; align-items: baseline; font-weight: 650; }
|
||||||
|
.single-ai-title .ant-typography { font-size: 12px; font-weight: normal; }
|
||||||
|
.single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(310px, .82fr); gap: 18px; align-items: start; }
|
||||||
|
.single-ai-media, .single-ai-settings { display: grid; gap: 14px; min-width: 0; }
|
||||||
|
.single-ai-section { background: #fff; border: 1px solid #e9ebef; border-radius: 12px; padding: 16px; min-width: 0; }
|
||||||
|
.single-ai-section-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.single-ai-section-head strong { font-size: 14px; }
|
||||||
|
.single-ai-section-head .ant-typography { font-size: 12px; }
|
||||||
|
.single-ai-reference-grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: repeat(2, 1fr); aspect-ratio: 3 / 2; gap: 8px; min-height: 220px; }
|
||||||
|
.single-ai-reference-grid.outpaint { grid-template-columns: 1fr; }
|
||||||
|
.single-ai-main-image, .single-ai-aux-image { position: relative; min-width: 0; min-height: 0; overflow: hidden; border-radius: 9px; background: #f3f4f6; }
|
||||||
|
.single-ai-main-image { grid-row: span 2; }
|
||||||
|
.single-ai-main-image img, .single-ai-aux-image img { width: 100%; height: 100%; display: block; object-fit: contain; }
|
||||||
|
.single-ai-main-image > span, .single-ai-aux-image > span { position: absolute; left: 8px; bottom: 8px; border-radius: 5px; padding: 3px 7px; background: rgba(20, 24, 33, .7); color: #fff; font-size: 11px; }
|
||||||
|
.single-ai-aux-image > .ant-btn { position: absolute; top: 6px; right: 6px; }
|
||||||
|
.single-ai-aux-image > button:not(.ant-btn) { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #c9ccd3; background: #fafafa; cursor: pointer; color: #737984; }
|
||||||
|
.single-ai-aux-image > button:not(.ant-btn) span { position: static; background: none; color: inherit; }
|
||||||
|
.single-ai-aux-image > .single-ai-change-ref { position: absolute !important; right: 6px; bottom: 6px; width: auto !important; height: auto !important; padding: 3px 7px; border: 0 !important; border-radius: 5px; background: rgba(20, 24, 33, .7) !important; color: white !important; font-size: 11px; }
|
||||||
|
.single-ai-result-frame { width: min(100%, 300px); max-height: 440px; margin: 0 auto 10px; overflow: hidden; background: #f7f8fa; border: 1px dashed #d9dde4; border-radius: 9px; display: flex; justify-content: center; align-items: center; }
|
||||||
|
.single-ai-result-frame .ant-image { display: block; width: 100%; height: 100%; }
|
||||||
|
.single-ai-result-frame .ant-image img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px; text-align: center; color: #808793; font-size: 12px; }
|
||||||
|
.single-ai-empty strong { color: #30343b; font-size: 14px; }
|
||||||
|
.single-ai-sparkle { color: #25ab65; font-size: 32px; }
|
||||||
|
.single-ai-settings .ant-typography-secondary { display: block; margin-top: 6px; font-size: 12px; }
|
||||||
|
.single-ai-style-grid { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||||
|
.single-ai-style-grid button { border: 1px solid #dce0e6; background: #fff; border-radius: 18px; padding: 5px 12px; cursor: pointer; }
|
||||||
|
.single-ai-style-grid button.selected { border-color: #20a260; color: #16874f; background: #effaf3; }
|
||||||
|
.single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 74px; gap: 7px; margin-bottom: 8px; }
|
||||||
|
.single-ai-dimensions .ant-input-number { width: 100%; }
|
||||||
|
.single-ai-advanced { display: grid; gap: 8px; }
|
||||||
|
.single-ai-picker { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-height: 420px; overflow: auto; }
|
||||||
|
.single-ai-picker button { min-width: 0; padding: 4px; border: 1px solid #ddd; background: #fff; border-radius: 8px; cursor: pointer; }
|
||||||
|
.single-ai-picker img { display: block; width: 100%; aspect-ratio: 1; object-fit: contain; }
|
||||||
|
.single-ai-picker span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
|
||||||
|
.single-ai-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
.single-ai-footer > div { display: flex; gap: 8px; }
|
||||||
|
@media (max-width: 760px) { .single-ai-layout { grid-template-columns: 1fr; } .single-ai-footer > .ant-typography { display: none; } .single-ai-footer { justify-content: flex-end; } }
|
||||||
@@ -1,40 +1,61 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
|
import { Alert, Button, Checkbox, Drawer, Image, Input, InputNumber, message, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorMessage } from '@/services/api';
|
||||||
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, WatermarkPayload } from '@/services/suite';
|
import {
|
||||||
|
DEFAULT_IMAGE_MODEL, STYLE_SET_OPTIONS, cancelSuite, getSuite,
|
||||||
|
imageEditSingle, ProductDimensions, WatermarkPayload,
|
||||||
|
} from '@/services/suite';
|
||||||
|
import './AiImageGenModal.css';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
type ImageOption = { url: string; name: string };
|
||||||
|
type Ratio = '3:4' | '1:1' | '4:5';
|
||||||
|
type Language = 'ru' | 'ar' | 'en';
|
||||||
|
type Background = 'auto' | 'white' | 'solid' | 'scene' | 'reference';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
productId: string;
|
productId: string;
|
||||||
/** 待生成的源图(采集图或生成图) */
|
source: (ImageOption & { afterAssetId?: string }) | null;
|
||||||
source: { url: string; name: string; afterAssetId?: string } | null;
|
referenceOptions: ImageOption[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
|
||||||
onGenerated?: (url: string) => void;
|
onGenerated?: (url: string) => void;
|
||||||
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
|
|
||||||
onSubmitted?: (suiteId: string) => void;
|
onSubmitted?: (suiteId: string) => void;
|
||||||
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
|
|
||||||
watermark?: WatermarkPayload;
|
watermark?: WatermarkPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
|
||||||
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
|
const [auxiliary, setAuxiliary] = useState<(ImageOption | null)[]>([null, null]);
|
||||||
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
|
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
|
||||||
*/
|
|
||||||
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
|
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
|
||||||
|
const [ratio, setRatio] = useState<Ratio>('3:4');
|
||||||
|
const [translateText, setTranslateText] = useState(false);
|
||||||
|
const [language, setLanguage] = useState<Language>('ru');
|
||||||
|
const [background, setBackground] = useState<Background>('auto');
|
||||||
|
const [backgroundDescription, setBackgroundDescription] = useState('');
|
||||||
|
const [styleSet, setStyleSet] = useState<number>(2);
|
||||||
|
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||||
|
const [showStylePrompt, setShowStylePrompt] = useState(false);
|
||||||
|
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
|
||||||
|
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [resultUrl, setResultUrl] = useState('');
|
const [resultUrl, setResultUrl] = useState('');
|
||||||
|
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
|
||||||
const [taskId, setTaskId] = useState('');
|
const [taskId, setTaskId] = useState('');
|
||||||
const [taskError, setTaskError] = useState('');
|
const [taskError, setTaskError] = useState('');
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pollAbortRef = useRef<AbortController | null>(null);
|
const pollAbortRef = useRef<AbortController | null>(null);
|
||||||
const pollRunRef = useRef(0);
|
const pollRunRef = useRef(0);
|
||||||
const activeTaskRef = useRef<string | null>(null);
|
const activeTaskRef = useRef<string | null>(null);
|
||||||
|
const previousSourceRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
|
||||||
|
.some((value) => typeof value === 'number' && value > 0);
|
||||||
|
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
|
||||||
|
const stylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((item) => item.value === styleSet)?.prompt ?? '';
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
pollRunRef.current += 1;
|
pollRunRef.current += 1;
|
||||||
@@ -44,17 +65,25 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
pollAbortRef.current = null;
|
pollAbortRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !activeTaskRef.current) {
|
if (!open || !source || activeTaskRef.current || previousSourceRef.current === source.url) return;
|
||||||
|
previousSourceRef.current = source.url;
|
||||||
|
setAuxiliary([null, null]);
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
|
setMode('edit');
|
||||||
|
setRatio('3:4');
|
||||||
|
setTranslateText(false);
|
||||||
|
setLanguage('ru');
|
||||||
|
setBackground('auto');
|
||||||
|
setBackgroundDescription('');
|
||||||
|
setDimensions({ unit: 'cm' });
|
||||||
|
setSizeChartOverride(null);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
setGenerating(false);
|
setResultRatio('3:4');
|
||||||
setTaskId('');
|
|
||||||
setTaskError('');
|
setTaskError('');
|
||||||
}
|
setTaskId('');
|
||||||
}, [open]);
|
}, [open, source?.url]);
|
||||||
|
useEffect(() => { if (!open) previousSourceRef.current = null; }, [open]);
|
||||||
useEffect(() => () => stopPolling(), []);
|
useEffect(() => () => stopPolling(), []);
|
||||||
|
|
||||||
const pollTask = (suiteId: string) => {
|
const pollTask = (suiteId: string) => {
|
||||||
@@ -63,12 +92,8 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
pollAbortRef.current = controller;
|
pollAbortRef.current = controller;
|
||||||
activeTaskRef.current = suiteId;
|
activeTaskRef.current = suiteId;
|
||||||
const startedAt = Date.now();
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
const schedule = (delay: number) => { pollTimerRef.current = setTimeout(() => void poll(), delay); };
|
||||||
const schedule = (delayMs: number) => {
|
|
||||||
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
|
|
||||||
};
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (runId !== pollRunRef.current) return;
|
if (runId !== pollRunRef.current) return;
|
||||||
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
||||||
@@ -90,34 +115,24 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
if (result) {
|
if (result) {
|
||||||
setResultUrl(result.url);
|
setResultUrl(result.url);
|
||||||
setTaskError(suite.error || '');
|
setTaskError(suite.error || '');
|
||||||
message.success('生成完成,已追加到「生成结果」');
|
|
||||||
onGenerated?.(result.url);
|
onGenerated?.(result.url);
|
||||||
} else if (suite.status === 'cancelled') {
|
message.success('单张图片生成完成');
|
||||||
setTaskError('任务已取消');
|
|
||||||
message.info('任务已取消');
|
|
||||||
} else {
|
} else {
|
||||||
const detail = suite.error || '生成失败,未返回图片';
|
const detail = suite.status === 'cancelled' ? '任务已取消' : suite.error || '生成失败,未返回图片';
|
||||||
setTaskError(detail);
|
setTaskError(detail);
|
||||||
message.error(detail);
|
if (suite.status !== 'cancelled') message.error(detail);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
|
|
||||||
if (Date.now() - startedAt > 6 * 60_000) {
|
|
||||||
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
|
|
||||||
}
|
|
||||||
schedule(3000);
|
schedule(3000);
|
||||||
} catch (e) {
|
} catch {
|
||||||
if (controller.signal.aborted || runId !== pollRunRef.current) return;
|
if (controller.signal.aborted || runId !== pollRunRef.current) return;
|
||||||
failures += 1;
|
failures += 1;
|
||||||
if (failures >= 3) {
|
if (failures >= 3) {
|
||||||
finish();
|
finish();
|
||||||
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。';
|
setTaskError('查询进度失败,任务仍可能在后台运行;可在生成结果区域查看。');
|
||||||
setTaskError(detail);
|
|
||||||
message.error(detail);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
|
|
||||||
schedule(Math.min(3000 * (2 ** failures), 12_000));
|
schedule(Math.min(3000 * (2 ** failures), 12_000));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -125,33 +140,37 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
if (!source) return;
|
if (!source?.url || generating) return;
|
||||||
|
if (mode === 'edit' && background === 'reference' && !auxiliary.some(Boolean)) {
|
||||||
|
message.warning('请选择一张辅助参考图作为背景参考');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
|
setResultRatio(mode === 'outpaint' ? '3:4' : ratio);
|
||||||
setTaskError('');
|
setTaskError('');
|
||||||
try {
|
try {
|
||||||
const r = await imageEditSingle({
|
const response = await imageEditSingle({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
image_url: source.url,
|
main_image_url: source.url,
|
||||||
prompt: prompt.trim(),
|
reference_image_urls: mode === 'outpaint' ? [] : auxiliary.flatMap((item) => item ? [item.url] : []),
|
||||||
model,
|
prompt: prompt.trim(), mode, model: DEFAULT_IMAGE_MODEL,
|
||||||
append: true,
|
ratio: mode === 'outpaint' ? '3:4' : ratio, translate_text: mode === 'edit' && translateText,
|
||||||
after_asset_id: source.afterAssetId,
|
target_language: language, style_set: styleSet, style_prompt: stylePrompt,
|
||||||
|
background, background_description: backgroundDescription.trim() || null,
|
||||||
|
generate_size_chart: mode === 'edit' && generateSizeChart,
|
||||||
|
dimensions: hasDimensions ? dimensions : null,
|
||||||
|
append: true, after_asset_id: source.afterAssetId,
|
||||||
watermark: watermark?.enabled ? watermark : undefined,
|
watermark: watermark?.enabled ? watermark : undefined,
|
||||||
});
|
});
|
||||||
setTaskId(r.suite_id);
|
setTaskId(response.suite_id);
|
||||||
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
onSubmitted?.(response.suite_id);
|
||||||
if (onSubmitted) {
|
pollTask(response.suite_id);
|
||||||
onSubmitted(r.suite_id);
|
} catch (error) {
|
||||||
onClose();
|
const detail = apiErrorMessage(error);
|
||||||
} else {
|
setTaskError(detail);
|
||||||
// 独立使用该组件时仍保留自身轮询能力。
|
|
||||||
pollTask(r.suite_id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(apiErrorMessage(e));
|
|
||||||
setTaskError(apiErrorMessage(e));
|
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
|
message.error(detail);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,113 +182,89 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
activeTaskRef.current = null;
|
activeTaskRef.current = null;
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
setTaskId('');
|
setTaskId('');
|
||||||
setTaskError('任务已取消;已完成的图片会保留在生成结果中。');
|
setTaskError('任务已取消');
|
||||||
} catch (e) {
|
} catch (error) { message.error(apiErrorMessage(error)); }
|
||||||
message.error(apiErrorMessage(e));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const choices = Array.from(new Map(referenceOptions.filter((item) => item.url && item.url !== source?.url)
|
||||||
<>
|
.map((item) => [item.url, item])).values());
|
||||||
{open && (
|
const chooseReference = (item: ImageOption) => {
|
||||||
<div
|
if (choosingSlot === null) return;
|
||||||
style={{
|
setAuxiliary((previous) => previous.map((selected, index) =>
|
||||||
position: 'fixed',
|
index === choosingSlot ? item : selected?.url === item.url ? null : selected));
|
||||||
inset: 0,
|
setChoosingSlot(null);
|
||||||
background: 'rgba(0,0,0,0.45)',
|
};
|
||||||
zIndex: 1000,
|
|
||||||
display: 'flex',
|
return <>
|
||||||
alignItems: 'center',
|
<Drawer className="single-ai-drawer" title={<div className="single-ai-title">单张 AI 编辑 <Text type="secondary">每次生成 1 张</Text></div>}
|
||||||
justifyContent: 'center',
|
placement="right" width="min(1040px, 100vw)" open={open} onClose={onClose} destroyOnHidden={false}
|
||||||
}}
|
footer={<div className="single-ai-footer"><Text type="secondary">生成结果会同时保存到「生成结果」</Text><div>
|
||||||
onClick={onClose}
|
{generating && taskId && <Button danger onClick={() => void onCancelTask()}>取消任务</Button>}
|
||||||
>
|
<Button onClick={onClose}>关闭</Button>
|
||||||
<div
|
<Button type="primary" loading={generating} disabled={!source} onClick={() => void onGenerate()}>{resultUrl ? '重新生成 1 张' : '生成 1 张'}</Button>
|
||||||
style={{
|
</div></div>}>
|
||||||
background: '#fff',
|
<div className="single-ai-layout">
|
||||||
borderRadius: 10,
|
<div className="single-ai-media">
|
||||||
padding: 20,
|
<section className="single-ai-section">
|
||||||
width: 760,
|
<div className="single-ai-section-head"><strong>主图与辅助参考</strong><Text type="secondary">{mode === 'outpaint' ? '仅使用主图' : '主图固定 · 辅图最多 2 张'}</Text></div>
|
||||||
maxWidth: '92vw',
|
<div className={`single-ai-reference-grid${mode === 'outpaint' ? ' outpaint' : ''}`}>
|
||||||
maxHeight: '88vh',
|
<div className="single-ai-main-image"><img src={source?.url} alt={source?.name || '主图'} /><span>主图 · 编辑底图</span></div>
|
||||||
overflow: 'auto',
|
{mode === 'edit' && auxiliary.map((item, index) => <div className="single-ai-aux-image" key={index}>
|
||||||
}}
|
{item ? <><img src={item.url} alt={item.name} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setAuxiliary((previous) => previous.map((value, i) => i === index ? null : value))} /></>
|
||||||
onClick={(e) => e.stopPropagation()}
|
: <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /><span>添加辅图 {index + 1}</span></button>}
|
||||||
>
|
{item && <button className="single-ai-change-ref" type="button" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button>}
|
||||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
</div>)}
|
||||||
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
|
|
||||||
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
</section>
|
||||||
<div style={{ width: 260, flexShrink: 0 }}>
|
<section className="single-ai-section">
|
||||||
<Image
|
<div className="single-ai-section-head"><strong>AI 生成结果</strong><Text type="secondary">按 {resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio} 展示</Text></div>
|
||||||
src={source?.url}
|
<div className="single-ai-result-frame" style={{ aspectRatio: (resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio).replace(':', ' / ') }}>
|
||||||
alt="原图"
|
{resultUrl ? <Image src={resultUrl} alt="AI 生成结果" /> : generating ? <div className="single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会直接展示在这里</span></div>
|
||||||
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
|
: <div className="single-ai-empty"><span className="single-ai-sparkle">✦</span><strong>生成的图片会展示在这里</strong><span>主图是编辑底图,生成结果单独显示</span></div>}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
{resultUrl && <a href={resultUrl} target="_blank" rel="noreferrer">打开原图</a>}
|
||||||
<Input.TextArea
|
{taskError && <Alert style={{ marginTop: 10 }} type="error" showIcon message={taskError} />}
|
||||||
rows={5}
|
</section>
|
||||||
value={prompt}
|
</div>
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
<div className="single-ai-settings">
|
||||||
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
|
<section className="single-ai-section"><div className="single-ai-section-head"><strong>编辑要求</strong></div>
|
||||||
disabled={generating}
|
<Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder={mode === 'outpaint' ? '描述新增边缘的画面;原图区域会保持不变' : '描述要改什么,例如:保留商品外观,换成温暖的客厅背景,突出材质质感'} />
|
||||||
/>
|
</section>
|
||||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
|
||||||
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
|
{translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} />
|
||||||
<Select
|
<Text type="secondary">生成时识别并翻译图片文案,重新排版;不会额外发起识图请求。</Text></>}
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
</section>}
|
||||||
popupMatchSelectWidth={false}
|
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>背景</strong></div>
|
||||||
value={model}
|
<Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[
|
||||||
onChange={setModel}
|
{ value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' },
|
||||||
disabled={generating}
|
{ value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' },
|
||||||
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
]} />
|
||||||
optionRender={(option) => (
|
{['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
|
||||||
<div>
|
</section>}
|
||||||
<div>{option.label}</div>
|
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>视觉风格</strong></div>
|
||||||
<div style={{ fontSize: 11, color: '#999' }}>
|
<div className="single-ai-style-grid">{STYLE_SET_OPTIONS.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
|
||||||
{(option as { data?: { desc?: string } }).data?.desc}
|
<Button type="link" size="small" style={{ paddingLeft: 0, marginTop: 6 }} onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
|
||||||
|
{showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((previous) => ({ ...previous, [styleSet]: event.target.value }))} />}
|
||||||
|
</section>}
|
||||||
|
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
|
||||||
|
<div className="single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((previous) => ({ ...previous, [key]: typeof value === 'number' ? value : undefined }))} />)}
|
||||||
|
<Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((previous) => ({ ...previous, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
|
||||||
|
{hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
|
||||||
|
<Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向。</Text>
|
||||||
|
</section>}
|
||||||
|
<section className="single-ai-section"><div className="single-ai-section-head"><strong>画面比例</strong></div>
|
||||||
|
<Radio.Group optionType="button" buttonStyle="solid" value={mode === 'outpaint' ? '3:4' : ratio} disabled={generating || mode === 'outpaint'} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
|
||||||
|
<Button type="link" size="small" style={{ display: 'block', paddingLeft: 0, marginTop: 8 }} onClick={() => setAdvancedOpen(!advancedOpen)}>{advancedOpen ? '收起生成方式' : '生成方式'}</Button>
|
||||||
|
{advancedOpen && <div className="single-ai-advanced">
|
||||||
|
<Select value={mode} disabled={generating} onChange={(value: 'edit' | 'outpaint') => setMode(value)} options={[{ value: 'edit', label: 'AI 编辑' }, { value: 'outpaint', label: '仅扩图至 3:4(保留原图)' }]} />
|
||||||
|
<Text type="secondary">使用 Pcore 中转 · gpt-image-2{mode === 'outpaint' ? ';原图区域会覆盖回生成结果。' : ''}</Text>
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Drawer>
|
||||||
/>
|
<Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} footer={null} onCancel={() => setChoosingSlot(null)}>
|
||||||
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
|
<div className="single-ai-picker">{choices.length ? choices.map((item) => <button type="button" key={item.url} onClick={() => chooseReference(item)}><img src={item.url} alt={item.name} /><span>{item.name}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
|
||||||
提交任务
|
</Modal>
|
||||||
</Button>
|
</>;
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Alert
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
type={taskError && !generating ? 'error' : 'info'}
|
|
||||||
showIcon
|
|
||||||
message={
|
|
||||||
generating
|
|
||||||
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
|
|
||||||
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{generating && taskId && (
|
|
||||||
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
|
|
||||||
取消此任务
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{resultUrl && (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<Space style={{ marginBottom: 8 }}>
|
|
||||||
<Text strong>生成结果</Text>
|
|
||||||
<a href={resultUrl} target="_blank" rel="noreferrer">
|
|
||||||
打开原图
|
|
||||||
</a>
|
|
||||||
</Space>
|
|
||||||
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface RegisterRecord {
|
|||||||
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
|
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
|
||||||
sellingPriceRub: string;
|
sellingPriceRub: string;
|
||||||
sellingPriceRubReserved: string; // 划线价 ₽
|
sellingPriceRubReserved: string; // 划线价 ₽
|
||||||
lineMultiplier: number; // 划线价倍数 %(v1 discountReserve 位)
|
lineDiscountRate: number; // 划线折扣率 %
|
||||||
exchangeRate: string;
|
exchangeRate: string;
|
||||||
logisticsFee: string;
|
logisticsFee: string;
|
||||||
receivedPrice: string;
|
receivedPrice: string;
|
||||||
@@ -95,7 +95,7 @@ function toRecord(product: ProductDetail): RegisterRecord | null {
|
|||||||
sellingPriceReserved: f(pr.linePriceCny),
|
sellingPriceReserved: f(pr.linePriceCny),
|
||||||
sellingPriceRub: f(pr.sellingPriceRub),
|
sellingPriceRub: f(pr.sellingPriceRub),
|
||||||
sellingPriceRubReserved: f(pr.linePriceRub),
|
sellingPriceRubReserved: f(pr.linePriceRub),
|
||||||
lineMultiplier: pr.lineMultiplier ?? 100,
|
lineDiscountRate: pr.lineDiscountRate ?? 50,
|
||||||
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
|
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
|
||||||
logisticsFee: f(pr.logisticsFee),
|
logisticsFee: f(pr.logisticsFee),
|
||||||
receivedPrice: f(pr.receivedPrice),
|
receivedPrice: f(pr.receivedPrice),
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ interface GeneratedImageGridProps {
|
|||||||
export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) {
|
export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const { current, visible } = useVisiblePage(images, page);
|
const { current, visible } = useVisiblePage(images, page);
|
||||||
|
// 新完成的图片经过时间倒序会进入首位;无论用户此前停在哪一页,都直接展示最新结果。
|
||||||
|
const newestKey = images[0]?.key;
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [newestKey]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current !== page) setPage(current);
|
if (current !== page) setPage(current);
|
||||||
}, [current, page]);
|
}, [current, page]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiErrorMessage } from '@/services/api';
|
|||||||
import { copyText } from '@/utils/file';
|
import { copyText } from '@/utils/file';
|
||||||
import {
|
import {
|
||||||
calculatePricing,
|
calculatePricing,
|
||||||
|
legacyLineMultiplierToDiscountRate,
|
||||||
logisticsFeeRule,
|
logisticsFeeRule,
|
||||||
validateDimensions,
|
validateDimensions,
|
||||||
validateLogisticsLevelCny,
|
validateLogisticsLevelCny,
|
||||||
@@ -30,7 +31,7 @@ interface Props {
|
|||||||
type PricingPayload = Record<string, any>;
|
type PricingPayload = Record<string, any>;
|
||||||
type Dims = { l: number | null; w: number | null; h: number | null };
|
type Dims = { l: number | null; w: number | null; h: number | null };
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 {
|
function dimsCm(product: ProductDetail): Dims {
|
||||||
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
||||||
@@ -60,7 +61,7 @@ function SalePriceGrid({
|
|||||||
cnyLine,
|
cnyLine,
|
||||||
rubSelling,
|
rubSelling,
|
||||||
rubLine,
|
rubLine,
|
||||||
multiplier,
|
discountRate,
|
||||||
fxRate,
|
fxRate,
|
||||||
cnyGap,
|
cnyGap,
|
||||||
rubGap,
|
rubGap,
|
||||||
@@ -70,7 +71,7 @@ function SalePriceGrid({
|
|||||||
cnyLine: string;
|
cnyLine: string;
|
||||||
rubSelling: string;
|
rubSelling: string;
|
||||||
rubLine: string;
|
rubLine: string;
|
||||||
multiplier: number;
|
discountRate: number;
|
||||||
fxRate: number;
|
fxRate: number;
|
||||||
cnyGap: number | null;
|
cnyGap: number | null;
|
||||||
rubGap: number | null;
|
rubGap: number | null;
|
||||||
@@ -88,7 +89,7 @@ function SalePriceGrid({
|
|||||||
lineHeight: '30px',
|
lineHeight: '30px',
|
||||||
};
|
};
|
||||||
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' };
|
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 () => {
|
const copyCnySelling = async () => {
|
||||||
if (!cnySellingNumber) return;
|
if (!cnySellingNumber) return;
|
||||||
@@ -133,7 +134,7 @@ function SalePriceGrid({
|
|||||||
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
|
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
|
||||||
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
|
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
|
||||||
<div style={noteStyle}>
|
<div style={noteStyle}>
|
||||||
销售价 × {multiple}
|
划线折扣 {discountText}
|
||||||
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
|
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +142,7 @@ function SalePriceGrid({
|
|||||||
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
|
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
|
||||||
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
|
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
|
||||||
<div style={noteStyle}>
|
<div style={noteStyle}>
|
||||||
销售价 × {multiple}
|
划线折扣 {discountText}
|
||||||
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
|
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,18 +154,20 @@ function SalePriceGrid({
|
|||||||
/**
|
/**
|
||||||
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
|
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
|
||||||
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
|
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
|
||||||
* 划线价为「倍数」口径:划线价 = 销售价 × (1 + 倍数%),0-500% 线性可算(v2.1 调整)。
|
* 划线价按折扣率计算:划线价 = 销售价 ÷ (1 - 划线折扣率)。
|
||||||
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
|
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
|
||||||
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
|
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
|
||||||
* 尺寸不合规时不落计价结果。
|
* 尺寸不合规时不落计价结果。
|
||||||
*/
|
*/
|
||||||
export default function TrialPricingPanel({ product, onSave }: Props) {
|
export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||||
const pricing = (product.pricing ?? {}) as PricingPayload;
|
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 [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
|
||||||
const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3);
|
const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3);
|
||||||
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
|
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 [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||||
const [fxSource, setFxSource] = useState('');
|
const [fxSource, setFxSource] = useState('');
|
||||||
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
|
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
|
||||||
@@ -206,39 +209,43 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
|
|
||||||
const compute = (patch: PricingState = {}): PricingResult =>
|
const compute = (patch: PricingState = {}): PricingResult =>
|
||||||
calculatePricing({
|
calculatePricing({
|
||||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
purchasePrice: patch.purchasePrice ?? purchasePrice ?? 0,
|
||||||
profitRate: patch.profitRate ?? profitRate,
|
profitRate: patch.profitRate ?? profitRate,
|
||||||
logisticsLevel: patch.level ?? level,
|
logisticsLevel: patch.level ?? level,
|
||||||
weightG: wG(patch.weightG ?? weight),
|
weightG: wG(patch.weightG ?? weight),
|
||||||
dims: dimsNum(patch.dims ?? dims),
|
dims: dimsNum(patch.dims ?? dims),
|
||||||
tdPrice: patch.tdPrice ?? tdPrice,
|
tdPrice: patch.tdPrice ?? tdPrice,
|
||||||
lineMultiplier: patch.multiplier ?? multiplier,
|
lineDiscountRate: patch.discountRate ?? discountRate,
|
||||||
fxRate: patch.fxRate ?? fxRate,
|
fxRate: patch.fxRate ?? fxRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
|
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
|
||||||
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
|
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
|
||||||
const p = {
|
const p = {
|
||||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
purchasePrice: patch.purchasePrice !== undefined ? patch.purchasePrice : purchasePrice,
|
||||||
profitRate: patch.profitRate ?? profitRate,
|
profitRate: patch.profitRate ?? profitRate,
|
||||||
tdPrice: patch.tdPrice ?? tdPrice,
|
tdPrice: patch.tdPrice ?? tdPrice,
|
||||||
level: patch.level ?? level,
|
level: patch.level ?? level,
|
||||||
multiplier: patch.multiplier ?? multiplier,
|
discountRate: patch.discountRate ?? discountRate,
|
||||||
fxRate: patch.fxRate ?? fxRate,
|
fxRate: patch.fxRate ?? fxRate,
|
||||||
weightG: wG(patch.weightG ?? weight),
|
weightG: patch.weightG !== undefined ? patch.weightG : weight,
|
||||||
dimsNow: dimsNum(patch.dims ?? dims),
|
dimsNow: patch.dims ?? dims,
|
||||||
};
|
};
|
||||||
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level);
|
const complete = p.purchasePrice != null && p.weightG != null
|
||||||
const r = dimError ? null : calculatePricing({
|
&& [p.dimsNow.l, p.dimsNow.w, p.dimsNow.h].every((value) => value != null)
|
||||||
purchasePrice: p.purchasePrice,
|
&& 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,
|
profitRate: p.profitRate,
|
||||||
logisticsLevel: p.level,
|
logisticsLevel: p.level,
|
||||||
weightG: p.weightG,
|
weightG: p.weightG!,
|
||||||
dims: p.dimsNow,
|
dims: dimsForCalculation,
|
||||||
tdPrice: p.tdPrice,
|
tdPrice: p.tdPrice,
|
||||||
lineMultiplier: p.multiplier,
|
lineDiscountRate: p.discountRate,
|
||||||
fxRate: p.fxRate,
|
fxRate: p.fxRate,
|
||||||
});
|
}) : null;
|
||||||
onSave({
|
onSave({
|
||||||
...extraSave,
|
...extraSave,
|
||||||
pricing: {
|
pricing: {
|
||||||
@@ -247,7 +254,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
profitRate: p.profitRate,
|
profitRate: p.profitRate,
|
||||||
tdPrice: p.tdPrice,
|
tdPrice: p.tdPrice,
|
||||||
logisticsLevel: p.level,
|
logisticsLevel: p.level,
|
||||||
lineMultiplier: p.multiplier,
|
lineDiscountRate: p.discountRate,
|
||||||
fxRate: p.fxRate,
|
fxRate: p.fxRate,
|
||||||
weightG: p.weightG,
|
weightG: p.weightG,
|
||||||
dims: p.dimsNow,
|
dims: p.dimsNow,
|
||||||
@@ -276,7 +283,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
|
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
|
||||||
const onWeightChange = (v: number | null) => {
|
const onWeightChange = (v: number | null) => {
|
||||||
setWeight(v);
|
setWeight(v);
|
||||||
recalc({ weightG: v ?? 0 }, { weight: v, weight_unit: 'g' });
|
recalc({ weightG: v }, { weight: v, weight_unit: 'g' });
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
|
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
|
||||||
@@ -292,8 +299,11 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
|
|
||||||
const curWeightG = wG(weight);
|
const curWeightG = wG(weight);
|
||||||
const curDims = dimsNum(dims);
|
const curDims = dimsNum(dims);
|
||||||
const dimError = fxRate ? validateDimensions(curWeightG, curDims, level) : '';
|
const hasCompleteInputs = purchasePrice != null && weight != null
|
||||||
const preview = fxRate && !dimError ? compute() : 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 levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
|
||||||
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
|
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
|
||||||
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
|
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
|
||||||
@@ -310,6 +320,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
|
placeholder="请填写"
|
||||||
value={weight}
|
value={weight}
|
||||||
onChange={(v) => onWeightChange(v)}
|
onChange={(v) => onWeightChange(v)}
|
||||||
/>
|
/>
|
||||||
@@ -319,6 +330,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
|
placeholder="请填写"
|
||||||
value={dims.l}
|
value={dims.l}
|
||||||
onChange={(v) => onDimChange('l', v)}
|
onChange={(v) => onDimChange('l', v)}
|
||||||
/>
|
/>
|
||||||
@@ -328,6 +340,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
|
placeholder="请填写"
|
||||||
value={dims.w}
|
value={dims.w}
|
||||||
onChange={(v) => onDimChange('w', v)}
|
onChange={(v) => onDimChange('w', v)}
|
||||||
/>
|
/>
|
||||||
@@ -337,6 +350,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
|
placeholder="请填写"
|
||||||
value={dims.h}
|
value={dims.h}
|
||||||
onChange={(v) => onDimChange('h', v)}
|
onChange={(v) => onDimChange('h', v)}
|
||||||
/>
|
/>
|
||||||
@@ -344,15 +358,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
</Row>
|
</Row>
|
||||||
<Row gutter={16} style={fieldRowStyle}>
|
<Row gutter={16} style={fieldRowStyle}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<FieldLabel>进货价 ¥</FieldLabel>
|
<FieldLabel>净货价 ¥</FieldLabel>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
|
placeholder="请填写"
|
||||||
value={purchasePrice}
|
value={purchasePrice}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const n = v ?? 0;
|
setPurchasePrice(v);
|
||||||
setPurchasePrice(n);
|
recalc({ purchasePrice: v });
|
||||||
recalc({ purchasePrice: n });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -385,17 +399,18 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<FieldLabel>划线价倍数 %</FieldLabel>
|
<FieldLabel>划线折扣率 %</FieldLabel>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
min={0}
|
min={0}
|
||||||
max={500}
|
max={99.99}
|
||||||
step={5}
|
precision={2}
|
||||||
value={multiplier}
|
step={1}
|
||||||
|
value={discountRate}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const n = v ?? 0;
|
const n = v ?? 0;
|
||||||
setMultiplier(n);
|
setDiscountRate(n);
|
||||||
recalc({ multiplier: n });
|
recalc({ discountRate: n });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -450,7 +465,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
|
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
|
||||||
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||||
划线价 = 销售价 × (1 + 倍数%);参数与重量/尺寸变化自动计价并入库;尺寸不合规时不落计价结果。
|
划线价 = 销售价 ÷ (1 − 划线折扣率);折扣率需 ≥ 0 且 < 100%。填写净货价、重量和长宽高后自动计价。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{(levelHint || rangeHint) && (
|
{(levelHint || rangeHint) && (
|
||||||
@@ -496,15 +511,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<Metric
|
<Metric
|
||||||
label="完全成本"
|
label="完全成本"
|
||||||
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
|
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
|
||||||
rule="(进货价 + 物流费 + 平台总抽成)"
|
rule="(净货价 + 物流费 + 平台总抽成)"
|
||||||
color={COLOR_RED}
|
color={COLOR_RED}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" />
|
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(净货价 × 净利率)" />
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" />
|
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(净货价 × (1 + 净利率))" />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
@@ -515,7 +530,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
|
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
|
||||||
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
|
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
|
||||||
rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'}
|
rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'}
|
||||||
multiplier={multiplier}
|
discountRate={discountRate}
|
||||||
fxRate={fxRate}
|
fxRate={fxRate}
|
||||||
cnyGap={preview ? discountGap : null}
|
cnyGap={preview ? discountGap : null}
|
||||||
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : null}
|
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : null}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
||||||
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
|
* 交互对齐 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 {
|
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,
|
Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
|
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
|
||||||
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
|
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
|
||||||
applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset,
|
applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset,
|
||||||
type PlanItem,
|
type PlanItem, type ProductDimensions,
|
||||||
} from '@/services/suite';
|
} from '@/services/suite';
|
||||||
import { cleanFilename, downloadBlob } from '@/utils/file';
|
import { cleanFilename, downloadBlob } from '@/utils/file';
|
||||||
import AiImageGenModal from './AiImageGenModal';
|
import AiImageGenModal from './AiImageGenModal';
|
||||||
@@ -28,6 +28,9 @@ const { Text } = Typography;
|
|||||||
|
|
||||||
const WATERMARK_STORAGE_KEY = 'trialWatermark';
|
const WATERMARK_STORAGE_KEY = 'trialWatermark';
|
||||||
|
|
||||||
|
const hasAnyDimensions = (dimensions: ProductDimensions) =>
|
||||||
|
[dimensions.length, dimensions.width, dimensions.height].some((value) => typeof value === 'number' && value > 0);
|
||||||
|
|
||||||
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
|
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
|
||||||
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||||
const [text, setText] = useState(String(value));
|
const [text, setText] = useState(String(value));
|
||||||
@@ -158,12 +161,15 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
const [styleSet, setStyleSet] = useState(1);
|
const [styleSet, setStyleSet] = useState(1);
|
||||||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||||
const [requirements, setRequirements] = useState('');
|
const [requirements, setRequirements] = useState('');
|
||||||
|
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
|
||||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||||
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
|
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
|
||||||
|
|
||||||
// 生成
|
// 生成
|
||||||
const [suite, setSuite] = useState<SuiteInfo | null>(null);
|
const [suite, setSuite] = useState<SuiteInfo | null>(null);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
/** 同一商品可并行生成多套图:这里保留所有仍在运行的任务,避免新任务挤掉旧任务。 */
|
||||||
|
const [activeSuiteTasks, setActiveSuiteTasks] = useState<SuiteInfo[]>([]);
|
||||||
const [exportingResult, setExportingResult] = useState(false);
|
const [exportingResult, setExportingResult] = useState(false);
|
||||||
const [exportingImages, setExportingImages] = 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 [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
|
||||||
const [applyingWatermark, setApplyingWatermark] = useState(false);
|
const [applyingWatermark, setApplyingWatermark] = useState(false);
|
||||||
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
|
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const suitePollersRef = useRef(new Map<string, { timer: ReturnType<typeof setTimeout> | null; controller: AbortController }>());
|
||||||
const pollAbortRef = useRef<AbortController | null>(null);
|
const activeSuiteIdsRef = useRef(new Set<string>());
|
||||||
const pollRunRef = useRef(0);
|
/** 当前展示的任务;其他任务继续轮询,但不能把当前结果面板覆盖掉。 */
|
||||||
|
const focusedSuiteIdRef = useRef<string | null>(null);
|
||||||
const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const watermarkPollAbortRef = useRef<AbortController | null>(null);
|
const watermarkPollAbortRef = useRef<AbortController | null>(null);
|
||||||
const watermarkPollRunRef = useRef(0);
|
const watermarkPollRunRef = useRef(0);
|
||||||
@@ -208,15 +215,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
const suiteImages = (suite?.images ?? [])
|
const suiteImages = (suite?.images ?? [])
|
||||||
.filter((img) => img.status === 'done')
|
.filter((img) => img.status === 'done')
|
||||||
.map((img, index) => {
|
.map((img, index) => {
|
||||||
const asset = generatedAssets.find((a) => assetUrl(a) === img.url);
|
const asset = generatedAssets.find((a) => a.id === img.asset_id)
|
||||||
|
|| generatedAssets.find((a) => assetUrl(a) === img.url);
|
||||||
return {
|
return {
|
||||||
key: `suite-${suite?.id ?? 'current'}-${index}`,
|
// 素材已回写时与历史记录共用同一个 key,避免同一张图显示两次。
|
||||||
|
key: asset ? `asset-${asset.id}` : `suite-${suite?.id ?? 'current'}-${img.item_id || index}`,
|
||||||
url: img.url,
|
url: img.url,
|
||||||
name: img.name,
|
name: img.name,
|
||||||
groupName: '套图结果',
|
groupName: '生成结果',
|
||||||
variantName: null as string | null,
|
variantName: null as string | null,
|
||||||
assetId: asset?.id,
|
assetId: asset?.id,
|
||||||
source: 'suite' as const,
|
source: asset ? 'history' as const : 'suite' as const,
|
||||||
|
createdAt: asset?.created_at || img.completed_at || suite?.completed_at || suite?.created_at || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const historyImages = generatedAssets.map((asset) => ({
|
const historyImages = generatedAssets.map((asset) => ({
|
||||||
@@ -229,10 +239,22 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
source: 'history' as const,
|
source: 'history' as const,
|
||||||
createdAt: asset.created_at,
|
createdAt: asset.created_at,
|
||||||
}));
|
}));
|
||||||
return [...suiteImages, ...historyImages].filter((img) => !hiddenResultKeys.has(img.key));
|
const unique = new Map<string, ResultImage>();
|
||||||
|
// 已落库的素材带有最终 created_at,后写入可覆盖同 key 的临时 suite 结果。
|
||||||
|
[...suiteImages, ...historyImages].forEach((image) => unique.set(image.key, image));
|
||||||
|
return [...unique.values()]
|
||||||
|
.filter((img) => !hiddenResultKeys.has(img.key))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timestamp = (value?: string) => {
|
||||||
|
const parsed = value ? Date.parse(value) : Number.NaN;
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
const rightTime = timestamp(right.createdAt);
|
||||||
|
const leftTime = timestamp(left.createdAt);
|
||||||
|
return rightTime - leftTime || right.key.localeCompare(left.key);
|
||||||
|
});
|
||||||
}, [generatedAssets, hiddenResultKeys, suite]);
|
}, [generatedAssets, hiddenResultKeys, suite]);
|
||||||
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
|
const otherActiveSuiteTasks = activeSuiteTasks.filter((task) => task.id !== suite?.id);
|
||||||
const historyResultImages = resultImages.filter((img) => img.source === 'history');
|
|
||||||
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
|
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
|
||||||
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
|
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
|
||||||
const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => img.assetId!)));
|
const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => img.assetId!)));
|
||||||
@@ -259,12 +281,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assets]);
|
}, [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 = () => {
|
const stopPolling = () => {
|
||||||
pollRunRef.current += 1;
|
Array.from(suitePollersRef.current.keys()).forEach(stopSuitePolling);
|
||||||
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
||||||
pollTimerRef.current = null;
|
|
||||||
pollAbortRef.current?.abort();
|
|
||||||
pollAbortRef.current = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleKey = (key: string) => {
|
const toggleKey = (key: string) => {
|
||||||
@@ -340,39 +366,59 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? '';
|
const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? '';
|
||||||
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
|
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
|
||||||
|
|
||||||
const pollSuite = (suiteId: string) => {
|
const pollSuite = (suiteId: string, focus = true) => {
|
||||||
stopPolling();
|
// 只替换同一任务的轮询,绝不停止其他正在运行的套图任务。
|
||||||
const runId = ++pollRunRef.current;
|
stopSuitePolling(suiteId);
|
||||||
const controller = new AbortController();
|
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();
|
const startedAt = Date.now();
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
let timeoutWarned = false;
|
let timeoutWarned = false;
|
||||||
|
const storageKey = `trialSuiteTasks:${product.id}`;
|
||||||
|
const legacyStorageKey = `trialSuiteTask:${product.id}`;
|
||||||
try {
|
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 { /* 私密模式等不可写时不影响当前轮询 */ }
|
} catch { /* 私密模式等不可写时不影响当前轮询 */ }
|
||||||
|
|
||||||
const clearSavedTask = () => {
|
const clearSavedTask = () => {
|
||||||
try {
|
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 不可用 */ }
|
} catch { /* 忽略 sessionStorage 不可用 */ }
|
||||||
};
|
};
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (runId !== pollRunRef.current) return;
|
if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
|
||||||
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
stopSuitePolling(suiteId);
|
||||||
pollTimerRef.current = null;
|
activeSuiteIdsRef.current.delete(suiteId);
|
||||||
pollAbortRef.current = null;
|
setActiveSuiteTasks((previous) => previous.filter((task) => task.id !== suiteId));
|
||||||
setGenerating(false);
|
setGenerating(activeSuiteIdsRef.current.size > 0);
|
||||||
};
|
};
|
||||||
const schedule = (delayMs: number) => {
|
const schedule = (delayMs: number) => {
|
||||||
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
|
poller.timer = setTimeout(() => void poll(), delayMs);
|
||||||
};
|
};
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (runId !== pollRunRef.current || controller.signal.aborted) return;
|
if (suitePollersRef.current.get(suiteId)?.controller !== controller || controller.signal.aborted) return;
|
||||||
try {
|
try {
|
||||||
const s = await getSuite(suiteId, { signal: controller.signal });
|
const s = await getSuite(suiteId, { signal: controller.signal });
|
||||||
if (runId !== pollRunRef.current) return;
|
if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
|
||||||
setSuite(s);
|
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;
|
failCount = 0;
|
||||||
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
|
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
|
||||||
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
|
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
|
||||||
@@ -393,7 +439,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
// 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。
|
// 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。
|
||||||
schedule(3000);
|
schedule(3000);
|
||||||
} catch {
|
} catch {
|
||||||
if (controller.signal.aborted || runId !== pollRunRef.current) return;
|
if (controller.signal.aborted || suitePollersRef.current.get(suiteId)?.controller !== controller) return;
|
||||||
failCount += 1;
|
failCount += 1;
|
||||||
if (failCount >= 3) {
|
if (failCount >= 3) {
|
||||||
finish();
|
finish();
|
||||||
@@ -475,25 +521,41 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
schedule(0);
|
schedule(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 刷新试算页后自动恢复当前商品仍在运行的套图任务;服务重启导致的任务丢失会按常规错误提示处理。
|
// 刷新试算页后恢复当前商品的所有运行任务,而不是仅恢复最新一条。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let savedTaskId = '';
|
let savedTaskIds: string[] = [];
|
||||||
try {
|
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 不可用时跳过恢复 */ }
|
} catch { /* sessionStorage 不可用时跳过恢复 */ }
|
||||||
if (savedTaskId) {
|
if (savedTaskIds.length > 0) {
|
||||||
setGenerating(true);
|
void Promise.all(savedTaskIds.map((id) => getSuite(id).catch(() => null))).then((tasks) => {
|
||||||
pollSuite(savedTaskId);
|
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 {
|
} else {
|
||||||
void listProductSuites(product.id, true).then((active) => {
|
void listProductSuites(product.id, true).then((active) => {
|
||||||
const task = active[0];
|
if (active.length === 0) return;
|
||||||
if (!task) return;
|
focusedSuiteIdRef.current = active[0].id;
|
||||||
setSuite(task);
|
setSuite(active[0]);
|
||||||
setGenerating(true);
|
active.forEach((task, index) => pollSuite(task.id, index === 0));
|
||||||
pollSuite(task.id);
|
|
||||||
}).catch(() => { /* 网络失败时不打断试算页使用 */ });
|
}).catch(() => { /* 网络失败时不打断试算页使用 */ });
|
||||||
}
|
}
|
||||||
return () => stopPolling();
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
activeSuiteIdsRef.current.clear();
|
||||||
|
setActiveSuiteTasks([]);
|
||||||
|
setGenerating(false);
|
||||||
|
};
|
||||||
// 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。
|
// 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [product.id]);
|
}, [product.id]);
|
||||||
@@ -531,7 +593,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
message.warning('出图方案的张数都是 0');
|
message.warning('出图方案的张数都是 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activePlan.some((item) => item.kind === 'size_chart') && !hasAnyDimensions(dimensions)) {
|
||||||
|
message.warning('方案包含尺寸标注图,请先在右侧填写至少一项商品尺寸及单位,或将该项数量设为 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
|
focusedSuiteIdRef.current = null;
|
||||||
setSuite(null);
|
setSuite(null);
|
||||||
try {
|
try {
|
||||||
const { suite_id } = await generateSuite({
|
const { suite_id } = await generateSuite({
|
||||||
@@ -543,6 +610,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
style_set: styleSet,
|
style_set: styleSet,
|
||||||
style_prompt: currentStylePrompt || null,
|
style_prompt: currentStylePrompt || null,
|
||||||
requirements: requirements.trim() || null,
|
requirements: requirements.trim() || null,
|
||||||
|
dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
|
||||||
plan: activePlan,
|
plan: activePlan,
|
||||||
platform: 'ozon',
|
platform: 'ozon',
|
||||||
model,
|
model,
|
||||||
@@ -561,9 +629,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
try {
|
try {
|
||||||
const next = await cancelSuite(suite.id);
|
const next = await cancelSuite(suite.id);
|
||||||
setSuite(next);
|
setSuite(next);
|
||||||
stopPolling();
|
stopSuitePolling(suite.id);
|
||||||
setGenerating(false);
|
activeSuiteIdsRef.current.delete(suite.id);
|
||||||
try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* ignore */ }
|
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('已取消生成任务;已完成的图片会保留在生成记录中');
|
message.info('已取消生成任务;已完成的图片会保留在生成记录中');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(apiErrorMessage(e));
|
message.error(apiErrorMessage(e));
|
||||||
@@ -641,6 +718,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
image_stats: stats,
|
image_stats: stats,
|
||||||
platform: 'ozon',
|
platform: 'ozon',
|
||||||
requirements: requirements.trim() || null,
|
requirements: requirements.trim() || null,
|
||||||
|
dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
|
||||||
});
|
});
|
||||||
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
|
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
|
||||||
setPlan(data.items);
|
setPlan(data.items);
|
||||||
@@ -686,7 +764,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
|
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,统一放在 ZIP 根目录) ──
|
||||||
const handleExportResult = async () => {
|
const handleExportResult = async () => {
|
||||||
if (resultImages.length === 0) {
|
if (resultImages.length === 0) {
|
||||||
message.warning('暂无生图结果可导出');
|
message.warning('暂无生图结果可导出');
|
||||||
@@ -705,7 +783,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
variantName: img.variantName,
|
variantName: img.variantName,
|
||||||
key: img.key,
|
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`);
|
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(apiErrorMessage(e));
|
message.error(apiErrorMessage(e));
|
||||||
@@ -725,6 +803,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
});
|
});
|
||||||
const name = cleanFilename(img.name || img.key) || '生图结果';
|
const name = cleanFilename(img.name || img.key) || '生图结果';
|
||||||
const suffix = (() => {
|
const suffix = (() => {
|
||||||
|
const mimeExt: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif', 'image/bmp': '.bmp',
|
||||||
|
};
|
||||||
|
const fromMime = mimeExt[blob.type.split(';')[0].toLowerCase()];
|
||||||
|
if (fromMime) return fromMime;
|
||||||
try {
|
try {
|
||||||
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
|
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1133,6 +1217,31 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
|
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>商品尺寸(长 × 宽 × 高;未知方向可留空)</Text>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
||||||
|
{(['length', 'width', 'height'] as const).map((key, index) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
controls={false}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={dimensions[key]}
|
||||||
|
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
|
||||||
|
placeholder={['长', '宽', '高'][index]}
|
||||||
|
/>
|
||||||
|
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Select
|
||||||
|
style={{ width: 76, flexShrink: 0 }}
|
||||||
|
value={dimensions.unit ?? 'cm'}
|
||||||
|
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
|
||||||
|
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
||||||
@@ -1218,6 +1327,30 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
|
<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 && (
|
{suite && (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
@@ -1267,8 +1400,21 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
|
||||||
|
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{resultImages.length > 0 && (
|
||||||
|
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
|
||||||
|
<Text strong>
|
||||||
|
全部生成结果
|
||||||
|
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
|
||||||
|
(按完成时间倒序 · 最新结果在第一页首位 · {resultImages.length} 张)
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
<GeneratedImageGrid
|
<GeneratedImageGrid
|
||||||
images={suiteResultImages}
|
images={resultImages}
|
||||||
selectedKeys={selectedResultKeys}
|
selectedKeys={selectedResultKeys}
|
||||||
downloadingKeys={downloadingResultKeys}
|
downloadingKeys={downloadingResultKeys}
|
||||||
preview={imagePreviewConfig}
|
preview={imagePreviewConfig}
|
||||||
@@ -1277,30 +1423,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
onDownload={(image) => void handleDownloadResultImage(image)}
|
onDownload={(image) => void handleDownloadResultImage(image)}
|
||||||
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
|
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
|
||||||
/>
|
/>
|
||||||
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
|
|
||||||
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
|
|
||||||
{historyResultImages.length > 0 && (
|
|
||||||
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
|
|
||||||
<Text strong>
|
|
||||||
生图记录
|
|
||||||
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
|
|
||||||
(一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张)
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
<div style={{ marginTop: 10 }}>
|
|
||||||
<GeneratedImageGrid
|
|
||||||
images={historyResultImages}
|
|
||||||
selectedKeys={selectedResultKeys}
|
|
||||||
downloadingKeys={downloadingResultKeys}
|
|
||||||
preview={imagePreviewConfig}
|
|
||||||
onToggle={toggleResultKey}
|
|
||||||
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
|
|
||||||
onDownload={(image) => void handleDownloadResultImage(image)}
|
|
||||||
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1321,11 +1443,14 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
open={genModal !== null}
|
open={genModal !== null}
|
||||||
productId={product.id}
|
productId={product.id}
|
||||||
source={genModal}
|
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}
|
watermark={watermark.enabled ? watermark : undefined}
|
||||||
onClose={() => setGenModal(null)}
|
onClose={() => setGenModal(null)}
|
||||||
onGenerated={() => onRefreshAssets()}
|
onGenerated={() => onRefreshAssets()}
|
||||||
onSubmitted={(suiteId) => {
|
onSubmitted={(suiteId) => {
|
||||||
setGenerating(true);
|
|
||||||
pollSuite(suiteId);
|
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 { Alert, App, Button, Card, Empty, Image, Modal, Popconfirm, Space, Tag, Typography } from 'antd';
|
||||||
import type { ProductAsset } from '@/services/product';
|
import type { ProductAsset } from '@/services/product';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
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 { Text } = Typography;
|
||||||
const urlOf = (asset: ProductAsset) => asset.stored_url || asset.source_url;
|
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 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';
|
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;
|
job: VideoJobInfo;
|
||||||
removing: boolean;
|
removing: boolean;
|
||||||
|
downloading: boolean;
|
||||||
onCancel: (id: string) => void;
|
onCancel: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onRetry: (id: string) => void;
|
onRetry: (id: string) => void;
|
||||||
|
onDownload: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const active = ['queued', 'running'].includes(job.status);
|
const active = ['queued', 'running'].includes(job.status);
|
||||||
const done = job.status === 'done' && !!job.result?.url;
|
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 }}>
|
<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>
|
<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>
|
<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>}
|
{failed && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={() => onRetry(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>重试</Button>}
|
||||||
</div>
|
</div>
|
||||||
{job.error && <Text type="danger" ellipsis={{ tooltip: job.error }} style={{ display: 'block', fontSize: 12, marginTop: 5 }}>{job.error}</Text>}
|
{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 [selected, setSelected] = useState<string[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
const [jobs, setJobs] = useState<VideoJobInfo[]>([]);
|
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]);
|
const candidates = useMemo(() => assets.filter((asset) => asset.type === 'img' && asset.status === 'uploaded' && !!urlOf(asset)).sort((a, b) => Number(b.group_key === 'generated') - Number(a.group_key === 'generated') || (b.created_at || '').localeCompare(a.created_at || '')), [assets]);
|
||||||
|
|
||||||
@@ -75,16 +79,26 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
|
|||||||
try { await deleteVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
|
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 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>
|
return <div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<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>
|
<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>
|
<Button type="primary" size="small" icon={<VideoCameraOutlined />} disabled={candidates.length < 2} onClick={() => setOpen(true)}>合成视频</Button>
|
||||||
</div>
|
</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="选择轮播图片">
|
<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 }} />
|
<Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} />
|
||||||
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
|
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 510, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
|
||||||
{selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>}
|
{selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
|
* 计价纯函数 —— 从 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
|
weightG: number; // 重量 g
|
||||||
dims: { l: number; w: number; h: number }; // cm
|
dims: { l: number; w: number; h: number }; // cm
|
||||||
tdPrice: number; // 贴单费用 ¥
|
tdPrice: number; // 贴单费用 ¥
|
||||||
lineMultiplier: number; // 划线价倍数 %(划线价 = 销售价 × (1 + 倍数/100))
|
lineDiscountRate: number; // 划线折扣率 %(划线价 = 销售价 ÷ (1 - 折扣率/100))
|
||||||
fxRate: number; // 汇率 CNY→RUB
|
fxRate: number; // 汇率 CNY→RUB
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export interface PricingResult {
|
|||||||
commission: number; // 展示用平台佣金
|
commission: number; // 展示用平台佣金
|
||||||
totalCost: number; // 完全成本
|
totalCost: number; // 完全成本
|
||||||
sellingPriceRub: number; // 销售价 ₽
|
sellingPriceRub: number; // 销售价 ₽
|
||||||
linePriceCny: number; // 划线价 ¥(销售价 × (1 + 倍数))
|
linePriceCny: number; // 划线价 ¥
|
||||||
linePriceRub: number; // 划线价 ₽
|
linePriceRub: number; // 划线价 ₽
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,10 @@ export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function calculatePricing(input: PricingInput): PricingResult {
|
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 logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
|
||||||
const profitDecimal = profitRate / 100;
|
const profitDecimal = profitRate / 100;
|
||||||
@@ -58,7 +61,7 @@ export function calculatePricing(input: PricingInput): PricingResult {
|
|||||||
const totalCost = purchasePrice + logisticsFee + fullCommission;
|
const totalCost = purchasePrice + logisticsFee + fullCommission;
|
||||||
|
|
||||||
const sellingPriceRub = sellingPriceCny * fxRate;
|
const sellingPriceRub = sellingPriceCny * fxRate;
|
||||||
const linePriceCny = sellingPriceCny * (1 + lineMultiplier / 100);
|
const linePriceCny = sellingPriceCny / (1 - lineDiscountRate / 100);
|
||||||
const linePriceRub = linePriceCny * fxRate;
|
const linePriceRub = linePriceCny * fxRate;
|
||||||
|
|
||||||
return {
|
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 {
|
export function validateLogisticsLevel(sellingPriceRub: number, level: LogisticsLevel): string {
|
||||||
if (sellingPriceRub > 140 && level === 'low') return '售价较高,建议选高等级物流';
|
if (sellingPriceRub > 140 && level === 'low') return '售价较高,建议选高等级物流';
|
||||||
|
|||||||
@@ -11,7 +11,17 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
|
// responseType: 'blob' 也会把 JSON 错误体包装成 Blob,先还原 detail。
|
||||||
|
const data = error.response?.data;
|
||||||
|
const contentType = error.response?.headers?.['content-type'] || data?.type || '';
|
||||||
|
if (data instanceof Blob && contentType.includes('json')) {
|
||||||
|
try {
|
||||||
|
error.response.data = JSON.parse(await data.text());
|
||||||
|
} catch {
|
||||||
|
// 保留原始响应,由调用方按普通请求错误处理。
|
||||||
|
}
|
||||||
|
}
|
||||||
if (envConfig.debug) {
|
if (envConfig.debug) {
|
||||||
console.error('请求失败:', error.response?.data || error.message);
|
console.error('请求失败:', error.response?.data || error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export interface PlanItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
|
/** 默认方案: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,
|
kind: t.value,
|
||||||
title: t.label,
|
title: t.label,
|
||||||
detail: '',
|
detail: '',
|
||||||
@@ -41,13 +44,21 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) =
|
|||||||
variant_name: null,
|
variant_name: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** 商品外廓尺寸;单位可选 mm 或 cm。 */
|
||||||
|
export interface ProductDimensions {
|
||||||
|
length?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
unit?: 'mm' | 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
|
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
|
||||||
export const STYLE_SET_OPTIONS = [
|
export const STYLE_SET_OPTIONS = [
|
||||||
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
|
{ value: 1, label: '北欧极简', prompt: 'Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
|
||||||
{ value: 2, label: '清新明亮', prompt: 'Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition' },
|
{ value: 2, label: '清新明亮', prompt: 'Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
|
||||||
{ value: 3, label: '高级感深色', prompt: 'Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood' },
|
{ value: 3, label: '高级感深色', prompt: 'Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
|
||||||
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere' },
|
{ value: 4, label: '暖调生活', prompt: 'Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
|
||||||
{ value: 5, label: '纯净棚拍', prompt: 'Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent' },
|
{ value: 5, label: '纯净棚拍', prompt: 'Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
|
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
|
||||||
@@ -100,6 +111,7 @@ export interface SuitePlanPayload {
|
|||||||
image_stats: Record<string, number>;
|
image_stats: Record<string, number>;
|
||||||
platform: string;
|
platform: string;
|
||||||
requirements?: string | null;
|
requirements?: string | null;
|
||||||
|
dimensions?: ProductDimensions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuiteGeneratePayload {
|
export interface SuiteGeneratePayload {
|
||||||
@@ -113,15 +125,18 @@ export interface SuiteGeneratePayload {
|
|||||||
platform: string;
|
platform: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
watermark?: WatermarkPayload;
|
watermark?: WatermarkPayload;
|
||||||
|
dimensions?: ProductDimensions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuiteImageInfo {
|
export interface SuiteImageInfo {
|
||||||
item_id: string;
|
item_id: string;
|
||||||
|
asset_id?: string | null;
|
||||||
type_id: string;
|
type_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
status: string;
|
status: string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuiteInfo {
|
export interface SuiteInfo {
|
||||||
@@ -185,14 +200,32 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
|
|||||||
|
|
||||||
export interface ImageEditSinglePayload {
|
export interface ImageEditSinglePayload {
|
||||||
product_id?: string;
|
product_id?: string;
|
||||||
image_url: string;
|
/** 明确的生成底图;辅助图始终在其后。 */
|
||||||
|
main_image_url?: string;
|
||||||
|
reference_image_urls?: string[];
|
||||||
|
/** 兼容旧接口的单张底图;新界面使用 main_image_url。 */
|
||||||
|
image_url?: string;
|
||||||
|
/** 选中的参考底图,可多张。 */
|
||||||
|
image_urls?: string[];
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
|
||||||
|
mode?: 'edit' | 'outpaint';
|
||||||
model: string;
|
model: string;
|
||||||
|
ratio?: '3:4' | '1:1' | '4:5';
|
||||||
|
target_language?: 'ru' | 'ar' | 'en';
|
||||||
|
translate_text?: boolean;
|
||||||
|
style_set?: number;
|
||||||
|
style_prompt?: string | null;
|
||||||
|
background?: 'auto' | 'white' | 'solid' | 'scene' | 'reference';
|
||||||
|
background_description?: string | null;
|
||||||
append?: boolean;
|
append?: boolean;
|
||||||
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
|
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
|
||||||
after_asset_id?: string;
|
after_asset_id?: string;
|
||||||
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
|
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
|
||||||
watermark?: WatermarkPayload;
|
watermark?: WatermarkPayload;
|
||||||
|
/** 勾选时将当前图按尺寸标注图任务处理。 */
|
||||||
|
generate_size_chart?: boolean;
|
||||||
|
dimensions?: ProductDimensions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
|
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
|
||||||
@@ -238,6 +271,8 @@ export function listProductWatermarkJobs(productId: string, activeOnly = false,
|
|||||||
export interface ExportImagesPayload {
|
export interface ExportImagesPayload {
|
||||||
title: string;
|
title: string;
|
||||||
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
|
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
|
||||||
|
/** 生成结果导出时忽略图片分组,统一放在 ZIP 的商品根目录。 */
|
||||||
|
flat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
|
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
|
||||||
@@ -305,6 +340,8 @@ export function deleteVideoJob(jobId: string) {
|
|||||||
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
|
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function videoDownloadUrl(jobId: string) {
|
/** 下载视频为 Blob,避免浏览器把 MP4 当作页面打开或预览。 */
|
||||||
return `/api/video-jobs/${jobId}/download`;
|
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