Compare commits
3 Commits
feat-2.1.1
...
897e5585c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 897e5585c0 | |||
| dbb2bb80bd | |||
| 27c5daed72 |
@@ -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 {
|
||||||
|
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
|
||||||
|
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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,97 @@
|
|||||||
|
/**
|
||||||
|
* 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本;
|
||||||
|
* 本模块只解析字符串和 HTML,绝不执行远端脚本。
|
||||||
|
*/
|
||||||
|
import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url';
|
||||||
|
import type { ImageMaterial } from './image';
|
||||||
|
import type { SiteProfile } from '../profiles/types';
|
||||||
|
|
||||||
|
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
|
||||||
|
|
||||||
|
function normalizeEscapedText(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||||
|
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||||
|
.replace(/\\\//g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(raw: string, baseUrl: string): string | null {
|
||||||
|
const value = raw.trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, baseUrl);
|
||||||
|
return /^https?:$/i.test(url.protocol) ? url.href : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUrl(out: string[], seen: Set<string>, raw: string | null, baseUrl: string): void {
|
||||||
|
if (!raw) return;
|
||||||
|
const url = normalizeUrl(raw, baseUrl);
|
||||||
|
if (!url || seen.has(url)) return;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */
|
||||||
|
export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const decoded = normalizeEscapedText(raw);
|
||||||
|
const doc = new DOMParser().parseFromString(decoded, 'text/html');
|
||||||
|
|
||||||
|
for (const el of Array.from(doc.querySelectorAll('img, source'))) {
|
||||||
|
for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl);
|
||||||
|
const srcset = el.getAttribute('srcset');
|
||||||
|
if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl);
|
||||||
|
}
|
||||||
|
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('[style]'))) {
|
||||||
|
addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
|
||||||
|
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl);
|
||||||
|
if (!url) continue;
|
||||||
|
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
|
||||||
|
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
|
||||||
|
if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 background service worker 跨域读取详情接口。 */
|
||||||
|
export function fetchDetailText(url: string, referer: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ action: 'fetchText', url, referer, credentials: true },
|
||||||
|
(response: { ok?: boolean; text?: string; error?: string } | undefined) => {
|
||||||
|
const runtimeError = chrome.runtime.lastError;
|
||||||
|
if (runtimeError) return reject(new Error(runtimeError.message));
|
||||||
|
if (!response?.ok || typeof response.text !== 'string') {
|
||||||
|
return reject(new Error(response?.error || '详情接口未返回内容'));
|
||||||
|
}
|
||||||
|
resolve(response.text);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: ImageMaterial[] = [];
|
||||||
|
for (const rawUrl of urls) {
|
||||||
|
const url = toOriginalUrl(rawUrl, profile.originalUrlRules);
|
||||||
|
const key = dedupeKey(url, profile.originalUrlRules);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push({
|
||||||
|
key: `detail-${String(result.length + 1).padStart(3, '0')}`,
|
||||||
|
groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl,
|
||||||
|
index: result.length, type: 'img',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -53,6 +53,25 @@ export function queryAllDeep(selectors: string[]): Element[] {
|
|||||||
return out;
|
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/*',
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -159,8 +160,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,
|
||||||
|
|||||||
+61
-8
@@ -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,
|
||||||
@@ -294,7 +330,20 @@ 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。
|
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
|
||||||
|
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
|
||||||
|
if not ref_urls:
|
||||||
|
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
|
||||||
|
if req.mode == "outpaint":
|
||||||
|
if provider_name != "pcore":
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2")
|
||||||
|
if len(ref_urls) != 1:
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
|
||||||
|
if req.generate_size_chart:
|
||||||
|
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
|
||||||
|
|
||||||
|
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||||
|
is_size_chart = bool(req.generate_size_chart)
|
||||||
task = await create_image_job(
|
task = await create_image_job(
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
kind="image_edit",
|
kind="image_edit",
|
||||||
@@ -304,20 +353,24 @@ 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=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": 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,
|
||||||
|
|||||||
+20
-2
@@ -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="mm", 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):
|
||||||
@@ -166,10 +177,15 @@ 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 代下)")
|
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="用户要求(可选,作为额外生图指令)")
|
||||||
|
mode: Literal["edit", "outpaint"] = Field(
|
||||||
|
default="edit",
|
||||||
|
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
|
||||||
|
)
|
||||||
model: str = Field(default="nano-banana-2")
|
model: str = Field(default="nano-banana-2")
|
||||||
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(
|
||||||
@@ -177,6 +193,8 @@ class ImageEditSingleRequest(BaseModel):
|
|||||||
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
|
||||||
@@ -84,8 +86,8 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
|
|||||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
return "1536x2048" if ratio == "3:4" else "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 "1152x1536" if ratio == "3:4" else "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"):
|
||||||
@@ -141,6 +143,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:
|
||||||
@@ -562,6 +633,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 +641,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))
|
||||||
|
|
||||||
@@ -650,6 +724,10 @@ async def advance_generation(
|
|||||||
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:
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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 +22,7 @@ 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.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
|
||||||
@@ -152,6 +153,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 +191,34 @@ 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:
|
||||||
|
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
|
||||||
|
operation = payload.get("operation", "edit")
|
||||||
|
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
|
||||||
|
edit_mask: str | None = None
|
||||||
|
outpaint: OutpaintCanvas | None = None
|
||||||
|
if operation == "outpaint":
|
||||||
|
if len(refs) != 1:
|
||||||
|
raise RuntimeError("仅扩图必须且只能使用一张参考底图")
|
||||||
|
prompt = _outpaint_prompt(payload.get("requirements"))
|
||||||
|
# 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。
|
||||||
|
if not claim.provider_task_id:
|
||||||
|
outpaint = await prepare_outpaint_canvas(refs[0], target_size)
|
||||||
|
refs = [outpaint.image_data_url]
|
||||||
|
edit_mask = outpaint.mask_data_url
|
||||||
|
else:
|
||||||
context = build_context(payload.get("context") or {}, fallback_name="")
|
context = build_context(payload.get("context") or {}, fallback_name="")
|
||||||
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 +236,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"):
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 "mm") if isinstance(dimensions, dict) else "mm"
|
||||||
|
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
|
||||||
|
known_labels = [label for label, _ in known_dimensions]
|
||||||
|
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
|
||||||
|
dimension_annotation_instruction = (
|
||||||
|
"STRICT MEASUREMENT OUTPUT RULE: render exactly "
|
||||||
|
+ str(len(known_labels))
|
||||||
|
+ " measurement arrows and labels, only for: "
|
||||||
|
+ ", ".join(known_labels)
|
||||||
|
+ ". The only permitted dimension values are: "
|
||||||
|
+ dimensions_line
|
||||||
|
+ ". Do not infer product measurements. Do not draw, label, or show an arrow for: "
|
||||||
|
+ ", ".join(missing_labels or ["any additional direction"])
|
||||||
|
+ ", depth, thickness, diameter, or any other unlisted dimension."
|
||||||
|
if known_dimensions else ""
|
||||||
|
)
|
||||||
params_line = "; ".join(
|
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()
|
||||||
|
|||||||
@@ -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,8 +1,10 @@
|
|||||||
"""单图导出响应头回归测试。"""
|
"""单图导出响应头回归测试。"""
|
||||||
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
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ 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):
|
||||||
@@ -27,6 +29,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()
|
||||||
@@ -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()
|
||||||
+16
-9
@@ -9,6 +9,13 @@ 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 进程。
|
||||||
@@ -141,7 +148,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 +159,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 +189,11 @@ 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
|
if ! command -v corepack >/dev/null 2>&1; then
|
||||||
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
|
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 依赖安装失败,本次只启动后端"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
|
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { CloseOutlined } 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, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ interface Props {
|
|||||||
productId: string;
|
productId: string;
|
||||||
/** 待生成的源图(采集图或生成图) */
|
/** 待生成的源图(采集图或生成图) */
|
||||||
source: { url: string; name: string; afterAssetId?: string } | null;
|
source: { url: string; name: string; afterAssetId?: string } | null;
|
||||||
|
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
|
||||||
|
referenceOptions: Array<{ url: string; name: string }>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
||||||
onGenerated?: (url: string) => void;
|
onGenerated?: (url: string) => void;
|
||||||
@@ -24,13 +26,17 @@ interface Props {
|
|||||||
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
|
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
|
||||||
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
|
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
|
||||||
*/
|
*/
|
||||||
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
|
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||||
|
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
|
||||||
|
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
|
||||||
|
const [generateSizeChart, setGenerateSizeChart] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [resultUrl, setResultUrl] = useState('');
|
const [resultUrl, setResultUrl] = useState('');
|
||||||
const [taskId, setTaskId] = useState('');
|
const [taskId, setTaskId] = useState('');
|
||||||
const [taskError, setTaskError] = useState('');
|
const [taskError, setTaskError] = useState('');
|
||||||
|
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set());
|
||||||
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);
|
||||||
@@ -48,12 +54,16 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !activeTaskRef.current) {
|
if (open && !activeTaskRef.current) {
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
|
setMode('edit');
|
||||||
|
setDimensions({ unit: 'mm' });
|
||||||
|
setGenerateSizeChart(false);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
setTaskId('');
|
setTaskId('');
|
||||||
setTaskError('');
|
setTaskError('');
|
||||||
|
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set());
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, source?.url]);
|
||||||
|
|
||||||
useEffect(() => () => stopPolling(), []);
|
useEffect(() => () => stopPolling(), []);
|
||||||
|
|
||||||
@@ -126,18 +136,35 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
if (selectedReferenceUrls.size === 0) {
|
||||||
|
message.warning('请至少选择一张参考底图');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'outpaint' && selectedReferenceUrls.size !== 1) {
|
||||||
|
message.warning('仅扩图只能选择一张参考底图');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasAnyDimensions = [dimensions.length, dimensions.width, dimensions.height]
|
||||||
|
.some((value) => typeof value === 'number' && value > 0);
|
||||||
|
if (generateSizeChart && !hasAnyDimensions) {
|
||||||
|
message.warning('生成尺寸标注图前,请至少填写一项商品尺寸');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
setTaskError('');
|
setTaskError('');
|
||||||
try {
|
try {
|
||||||
const r = await imageEditSingle({
|
const r = await imageEditSingle({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
image_url: source.url,
|
image_urls: Array.from(selectedReferenceUrls),
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
|
mode,
|
||||||
model,
|
model,
|
||||||
append: true,
|
append: true,
|
||||||
after_asset_id: source.afterAssetId,
|
after_asset_id: source.afterAssetId,
|
||||||
watermark: watermark?.enabled ? watermark : undefined,
|
watermark: watermark?.enabled ? watermark : undefined,
|
||||||
|
generate_size_chart: generateSizeChart,
|
||||||
|
dimensions: hasAnyDimensions ? dimensions : null,
|
||||||
});
|
});
|
||||||
setTaskId(r.suite_id);
|
setTaskId(r.suite_id);
|
||||||
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
||||||
@@ -169,6 +196,36 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const referenceImages = Array.from(
|
||||||
|
new Map(
|
||||||
|
[...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])]
|
||||||
|
.filter((image) => image.url)
|
||||||
|
.map((image) => [image.url, image]),
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleReference = (url: string) => {
|
||||||
|
if (generating) return;
|
||||||
|
setSelectedReferenceUrls((previous) => {
|
||||||
|
if (mode === 'outpaint') return new Set([url]);
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(url)) next.delete(url); else next.add(url);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMode = (nextMode: 'edit' | 'outpaint') => {
|
||||||
|
setMode(nextMode);
|
||||||
|
if (nextMode === 'outpaint') {
|
||||||
|
setGenerateSizeChart(false);
|
||||||
|
setModel('gpt-image-2@pcore');
|
||||||
|
setSelectedReferenceUrls((previous) => {
|
||||||
|
const first = previous.values().next().value as string | undefined;
|
||||||
|
return new Set(first ? [first] : (source?.url ? [source.url] : []));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -197,25 +254,106 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
|
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
|
||||||
|
<Radio.Group
|
||||||
|
size="small"
|
||||||
|
value={mode}
|
||||||
|
buttonStyle="solid"
|
||||||
|
disabled={generating}
|
||||||
|
onChange={(e) => changeMode(e.target.value)}
|
||||||
|
>
|
||||||
|
<Radio.Button value="edit">AI 编辑</Radio.Button>
|
||||||
|
<Radio.Button value="outpaint">仅扩图至 3:4</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
|
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 7 }}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>已选 {selectedReferenceUrls.size} 张</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 8, maxHeight: 190, overflowY: 'auto', padding: 2 }}>
|
||||||
|
{referenceImages.map((image) => {
|
||||||
|
const selected = selectedReferenceUrls.has(image.url);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={image.url}
|
||||||
|
type="button"
|
||||||
|
title={`${selected ? '取消选择' : '选择'}:${image.name}`}
|
||||||
|
onClick={() => toggleReference(image.url)}
|
||||||
|
disabled={generating}
|
||||||
|
style={{ position: 'relative', padding: 2, border: `2px solid ${selected ? '#1677ff' : '#e5e7eb'}`, borderRadius: 7, background: selected ? '#e6f4ff' : '#fff', cursor: generating ? 'not-allowed' : 'pointer', aspectRatio: '1 / 1', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<img src={image.url} alt={image.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', borderRadius: 4 }} />
|
||||||
|
{selected && <span style={{ position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: '50%', background: '#1677ff', color: '#fff', fontSize: 12, lineHeight: '18px' }}>✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<div style={{ width: 260, flexShrink: 0 }}>
|
<div style={{ width: 260, flexShrink: 0 }}>
|
||||||
<Image
|
<Image
|
||||||
src={source?.url}
|
src={source?.url}
|
||||||
alt="原图"
|
alt="当前图片"
|
||||||
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
|
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Input.TextArea
|
{mode === 'edit' && <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: generateSizeChart ? 12 : 0 }}>
|
||||||
rows={5}
|
<Checkbox
|
||||||
value={prompt}
|
checked={generateSizeChart}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setGenerateSizeChart(e.target.checked)}
|
||||||
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
|
disabled={generating}
|
||||||
|
>
|
||||||
|
生成尺寸标注图(未知方向留空)
|
||||||
|
</Checkbox>
|
||||||
|
</div>}
|
||||||
|
{mode === 'edit' && generateSizeChart && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{(['length', 'width', 'height'] as const).map((key, index) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
controls={false}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={dimensions[key]}
|
||||||
|
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
|
||||||
|
placeholder={['长', '宽', '高'][index]}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
/>
|
/>
|
||||||
|
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Select
|
||||||
|
style={{ width: 76, flexShrink: 0 }}
|
||||||
|
value={dimensions.unit ?? 'mm'}
|
||||||
|
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
|
||||||
|
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={mode === 'outpaint'
|
||||||
|
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
|
||||||
|
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
{mode === 'outpaint' && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
|
||||||
|
系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
|
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
|
||||||
<Select
|
<Select
|
||||||
@@ -224,7 +362,9 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={setModel}
|
onChange={setModel}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
options={IMAGE_MODEL_OPTIONS
|
||||||
|
.filter((m) => mode !== 'outpaint' || m.value === 'gpt-image-2@pcore')
|
||||||
|
.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
||||||
optionRender={(option) => (
|
optionRender={(option) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{option.label}</div>
|
<div>{option.label}</div>
|
||||||
@@ -234,11 +374,13 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Button type="primary" loading={generating} onClick={onGenerate}>
|
||||||
提交任务
|
提交任务
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Alert
|
<Alert
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
type={taskError && !generating ? 'error' : 'info'}
|
type={taskError && !generating ? 'error' : 'info'}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: 'mm' });
|
||||||
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);
|
||||||
@@ -233,6 +240,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
}, [generatedAssets, hiddenResultKeys, suite]);
|
}, [generatedAssets, hiddenResultKeys, suite]);
|
||||||
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
|
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
|
||||||
const historyResultImages = resultImages.filter((img) => img.source === 'history');
|
const historyResultImages = resultImages.filter((img) => img.source === 'history');
|
||||||
|
const otherActiveSuiteTasks = activeSuiteTasks.filter((task) => task.id !== suite?.id);
|
||||||
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
|
const 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 +267,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 +352,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 +425,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 +507,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 +579,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 +596,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 +615,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 +704,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 +750,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 +769,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));
|
||||||
@@ -1133,6 +1197,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 ?? 'mm'}
|
||||||
|
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 +1307,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 }}>
|
||||||
@@ -1321,11 +1434,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,13 +79,23 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
|
|||||||
try { await deleteVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
|
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: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
|
* 计价纯函数 —— 从 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 '售价较高,建议选高等级物流';
|
||||||
|
|||||||
@@ -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,6 +44,14 @@ 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 minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
|
||||||
@@ -100,6 +111,7 @@ export interface SuitePlanPayload {
|
|||||||
image_stats: Record<string, number>;
|
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,6 +125,7 @@ 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 {
|
||||||
@@ -185,14 +198,22 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
|
|||||||
|
|
||||||
export interface ImageEditSinglePayload {
|
export interface ImageEditSinglePayload {
|
||||||
product_id?: string;
|
product_id?: string;
|
||||||
image_url: string;
|
/** 兼容旧接口的单张底图;新界面使用 image_urls。 */
|
||||||
|
image_url?: string;
|
||||||
|
/** 选中的参考底图,可多张。 */
|
||||||
|
image_urls?: string[];
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
|
||||||
|
mode?: 'edit' | 'outpaint';
|
||||||
model: string;
|
model: string;
|
||||||
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 +259,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 +328,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