5 Commits

Author SHA1 Message Date
Joey 2835914fd8 feat: 迁移 ISS 服务端 2026-08-27 22:23:01 +08:00
R524809 efe3474deb feat: 采集插件整合进OSK 2026-08-27 17:18:50 +08:00
R524809 bb8f235e34 feat: 商品试算页面前端部分开发完成 2026-08-27 16:04:27 +08:00
R524809 7e1192f2ef feat: 价格试算模块优化 2026-08-27 10:35:07 +08:00
R524809 ac4ab22ac2 feat: 商品试算页 2026-08-26 17:38:44 +08:00
80 changed files with 15313 additions and 92 deletions
+9
View File
@@ -34,3 +34,12 @@ STORAGE_BACKEND=local
# ── V2:对外地址(插件/前端回写、生成图回调)──
APP_BASE_URL=http://127.0.0.1:8800
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
# 豆包 Seedream(火山方舟)
ARK_API_KEY=
# RightAPI 中转(gpt-image-2 / gpt-image-2-vip / nano-banana 系列)
RIGHTAPI_API_KEY=
# 可选覆盖:默认生图 providerdoubao | tongyi | rightapi)、水印徽章图路径
# IMAGE_PROVIDER=doubao
# WATERMARK_IMAGE_PATH=server/assets/watermark.jpg
@@ -0,0 +1,51 @@
## 采集功能对接计划(方案三:扩展并入主仓 + 上报开关 + 自动打开试算页)
**架构决策**(已确认):
- ISS 扩展整体并入主仓 **`extensions/collector/`**(复数容器目录容纳未来多个 Chrome 插件,子目录用功能名;extension-v2 原地冻结、docs 标注废弃)
- 整个项目作为一个应用部署(server 8800 + studio 8900 + 扩展独立构建)
- 「ISS 独立产品化」远期通过构建/配置裁剪实现(扩展天然独立分发;server 的 suite API 模块不依赖 products),**本次不实现配置化,只保证模块边界不阻碍**
- 本地阶段不做 Token/鉴权:`/api/materials` 用 settings 开关放开鉴权(`app_auth_enabled: bool = False`,远期可开回)
---
### Step 1:扩展并入主仓
1. 清空主仓 `extension/`(现为空壳,只有 .output/.wxt 残留)
2.`image-suite-studio/extension/` 整体复制到主仓 **`extensions/collector/`**src/collector、src/profiles、src/bridge、src/api、src/storage、entrypoints/{background,content,bridge.content,panel.content,sidepanel}、wxt.config.ts、package.json
3. 构建方式:`pnpm -C extensions/collector build`wxt 独立构建 → .output/chrome-mv3),不进 start.command(扩展是手动加载 unpacked
4. wxt.config.ts / package.json 的 name 改为 `ozon-collector-extension`(或同语境命名)
### Step 2:上报功能改造(并入版扩展内,~150 行)
1. **`src/storage/settings.ts`**BackendSettings 增加 `reportEnabled: boolean`(默认 true)、`reportBaseUrl: string`(默认 `http://127.0.0.1:8800`)、`studioBaseUrl: string`(默认 `http://localhost:8900`);loadSettings 合并逻辑补新字段默认值
2. **`src/api/report.ts`(新文件)**`reportProduct(reportBaseUrl, payload)``POST {reportBaseUrl}/api/materials`,返回 `{ product_id, created }`。payload 组装:`{ platform, item_id, url, texts, images: [{url, group_key, variant_name, type, width, height}] }`——texts 用现成 `editedTexts()`images 用 `buildGeneratePayload` 同款映射(ScanResult.images + uploadedImages 按 selectedKeys 过滤);实施时先读主仓 `server/schemas/collection.py` 对齐字段名
3. **`entrypoints/background.ts`**:照 `planSuite` 分支模式新增 action `reportProduct`L17-56 监听器内加一个分支)
4. **`entrypoints/sidepanel/App.tsx`**
- settingsPopupL615-670):新增「上报商品」开关 + 「OSK 后台地址」+ 「试算页地址」三个设置项
- 01 商品信息 Section 末尾(L784 警告框后):加「上报商品」按钮——reportEnabled 且已采集时可用;点击 → `send('reportProduct', ...)` → 成功后 `chrome.tabs.create({ url: \`${studioBaseUrl}/trial/${product_id}\` })` + 成功提示;失败弹错误
5. 面板内生图功能保持不变(本期不动)
### Step 3:主仓 server 小改(免鉴权开关)
1. `server/config/settings.py`:加 `app_auth_enabled: bool = False`
2. `server/deps.py``get_current_user``app_auth_enabled=False` 时直接返回占位用户(跳过 JWT 校验);True 时走现有 JWT 逻辑——远期开鉴权只改配置
3. 主仓 `/api/materials` 契约已兼容 ISS ScanResult(响应已含 `product_id`),无需改动;重复采集同一商品返回同一 product_id(已实现),自动打开即回到原试算页
### Step 4:文档
1. `docs/v2.1/collect.md`:更新为方案三(扩展并入 `extensions/collector/` + 上报开关 + 配置化独立预留),标注 D2 决策修订、extension-v2 冻结
2. `docs/v2.1/README.md`Phase C 状态与实施方式更新
3. 主仓 `README.md`:扩展部分改为指向 `extensions/collector/`
### Step 5:验证
1. `pnpm -C extensions/collector build` → Chrome 开发者模式加载 `.output/chrome-mv3`
2. Ozon / 1688 / 淘宝商详页:快速采集 → 面板出现数据 → 点「上报商品」→ 新标签自动打开 `http://localhost:8900/trial/{id}`,采集箱出现该商品
3. 同一商品重复采集上报 → 打开同一试算页(不重复建商品)
4. 后端未启动时上报 → 面板显示错误提示
5. 主仓 `pnpm build` + 类型检查通过
### 后续阶段(不在本次范围)
- **Phase B**:从 ISS server 平移 planner/generator/prompts/watermark/tasks 到主仓 server/api/suite/* 等),打通试算页一键生成与单张 AI 生图——试算页前端契约已按此写好
- 远期「ISS-only 产品模式」:studio 构建变量裁剪路由 + server 鉴权范围配置
+35 -40
View File
@@ -1,70 +1,65 @@
# Ozon Seller Kit
Ozon 跨境上品工具链:**采集 → 编辑 → 发布**
Ozon 跨境上品工具链:**采集 → 商品试算 → 登记导出**(V2.1 起主链路放弃 Ozon API 直传,人工上品,见 [`docs/v2.1/`](docs/v2.1/README.md)
## 四个组成部分
## 组成部分
| 目录 | 部分 | 状态 |
|---|---|---|
| `web/` | ① 工具台 v1:计价、登记、水印、俄文文案 | ✅ 在用(冻结 |
| `extension/` | ② Chrome 采集插件:Ozon / 1688 商品页采集 | 🔨 待开发 |
| `studio/` | ③ 发布工作台:AI 图生图(上传/水印/万相图生图) | 🔨 开发中 |
| `server/` | ④ FastAPIAI 文案、图生图(wanx2.1-imageedit)、Ozon API | 🔨 部分就绪 |
| `extensions/collector/` | Chrome 插件:三平台采集(Ozon/1688/淘宝/天猫)+ 套图生图面板 + **商品上报** | ✅ 在用(并入自 image-suite-studio |
| `studio/` | 商品试算工作台:计价、俄文文案、AI 生图、登记导出 | ✅ 开发 |
| `server/` | FastAPI:采集入库、商品库、AI 文案、图生图、汇率 | ✅ 部分就绪 |
| `web/` | 工具台 v1(计价/登记/水印/俄文文案) | 🧊 冻结(能力已迁入 studio) |
| `extension-v1/` `extension-v2/` | 旧版采集插件 | 🧊 冻结(被 collector 取代) |
| `docs/` | 全部文档 | — |
四者通过磁盘上的「[商品文件夹](docs/contracts/product-json.md)」契约衔接,不直接耦合代码
主链路:**collector 采集 → 「上报商品」入库(`/api/materials`)→ 自动打开试算页 `/trial/{id}` → 计价/文案/生图 → 录入登记表 → CSV/组合码 → 人工上 Ozon 后台**
## 目录
```
ozon-seller-kit/
├── server/ # ④ FastAPImain.py / api / services / schemas / config
├── web/ # ① 工具台 v1,冻结
├── extension/ # ② 采集插件(待建
├── studio/ # ③ 发布工作台(React + Vite + antdAI 图生图已上线)
├── packages/schema/ # 跨端共享契约(待建)
├── docs/ # 全部文档
├── server/ # FastAPImain.py / api / services / schemas / config
├── extensions/collector/ # Chrome 插件(WXT + Reactpnpm -C extensions/collector build
├── studio/ # 试算工作台(React 19 + Vite + antd
├── web/ # 工具台 v1,冻结
├── extension-v1/ v2/ # 旧采集插件,冻结
├── docs/ # 全部文档(现行方案在 docs/v2.1/)
├── start.command
└── .env.example
```
## 快速开始
**双击 `start.command`**,或
**1. 启动后端 + 前端**双击 `start.command`,或手动)
```bash
./start.command
./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900
```
打开 http://127.0.0.1:8800/ozonSeller.html
**2. 构建/加载采集插件**
```bash
pnpm -C extensions/collector install
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
```
Chrome → `chrome://extensions` → 开发者模式 → 加载已解压的扩展程序 → 选 `extensions/collector/.output/chrome-mv3`
打开 Ozon / 1688 / 淘宝 / 天猫商详页,点扩展图标(或页内悬浮「套」按钮)采集;
在面板「01 商品信息」下方点**「上报商品」**→ 自动入库并在新标签打开商品试算页。
- 密钥写在根目录 `.env`(参考 `.env.example`
- 可选模型写在 `server/config/models.yaml`
### 发布工作台(studio/AI 图生图)
先启动后端(同上 `./start.command`,或确保 `uvicorn` 跑在 8800),再启动前端:
```bash
cd studio
pnpm install
pnpm dev # http://localhost:8900(启动后自动打开浏览器)
```
前端通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8800`。图生图依赖阿里云百炼的
**wanx2.1-imageedit**(华北2/北京),需在 `.env` 配置:
```bash
DASHSCOPE_API_KEY=sk-xxxx
```
- 扩展面板「服务端设置」:套图服务端(默认 3300)、商品上报开关 + OSK 后台地址(默认 8800)+ 试算页地址(默认 8900)
## 文档
| 文档 | 内容 |
|---|---|
| [`docs/architecture.md`](docs/architecture.md) | **总体架构**,先读这个 |
| [`docs/contracts/product-json.md`](docs/contracts/product-json.md) | 商品文件夹契约(四部分的衔接点)|
| [`docs/extension/plan.md`](docs/extension/plan.md) | 插件方案(含 1688 插件逆向分析)|
| [`docs/extension/plan-revision.md`](docs/extension/plan-revision.md) | 插件方案修正(针对 Ozon 优先)|
| [`docs/deployment.md`](docs/deployment.md) | 部署与启动 |
| [`docs/ai-copy-backend-plan.md`](docs/ai-copy-backend-plan.md) | 俄文文案后端方案 |
| [`docs/studio/image-edit.md`](docs/studio/image-edit.md) | AI 图生图(studio 页面 + /api/image/edit |
| [`docs/v2.1/README.md`](docs/v2.1/README.md) | **现行方案总览**V2.1 转向:试算工作流) |
| [`docs/v2.1/collect.md`](docs/v2.1/collect.md) | 采集方案:扩展并入 + 上报开关 |
| [`docs/v2.1/trial-page.md`](docs/v2.1/trial-page.md) | 商品试算页设计 |
| [`docs/v2.1/image-suite.md`](docs/v2.1/image-suite.md) | 图片生成(套图 + 单张 AI 生图) |
| [`docs/v2.1/api.md`](docs/v2.1/api.md) | 前后端 API 契约 |
| [`docs/architecture.md`](docs/architecture.md) | V1 总体架构(历史参考) |
| [`docs/ozon-seller-api/`](docs/ozon-seller-api/) | Ozon Seller API 整理(远期重启直传时用 |
+146
View File
@@ -0,0 +1,146 @@
# Ozon Seller Kit V2.1 方案总览(转向:放弃 API 直传,聚焦试算工作流)
> 状态:方案定稿 + 分阶段实施(Phase A 前端先行,进行中)
> 最后更新:2026-08-26
> 定位:本文是 V2.1 全部设计文档的入口与决策总表。先读本文,再按需读分册。
> 前置:`docs/v2/`V2 方案)。V2.1 是对 V2 主链路的一次「降本转向」,不推翻 V2 的基础设施决策(数据库/存储/鉴权/工作台化)。
---
## 1. 背景与转向
V2 原定主链路是「采集 → 编辑 → **Ozon Seller API 直传发布**」。实施到发布环节发现:
ImportProductsV3 直传要求类目字典对齐、属性 id 映射(含 `is_aspect` 变体属性)、description_category_id/type_id 解析、
多变体合并规则、错误码逐条排查等大量细节字段对齐工作。对个人开发者而言实现成本过高、性价比不高。
V2.1 退而求其次,把主链路改为:
```
V2(原计划) V2.1(现在)
采集 → 采集箱 → 编辑 → API 直传发布 采集 → 采集箱 → 商品试算页 → CSV 导出
↑ ↑
大量 Ozon 字段对齐工作 人工在 Ozon 后台上品(粘贴/上传)
```
核心变化只有一条:**「上品」这一步从 API 自动化退回人工,把省下来的精力放在试算页的效率工具上**
(价格试算、俄文文案、AI 图片生成、采购地址维护、数据入库、CSV 导出)。
采集、采集箱、商品数据落库等 V2 已实现的基础全部保留。
---
## 2. 端到端数据流
```
① 浏览 Ozon / 1688 / 淘宝商详页
│ 插件页内悬浮面板 → 快速采集(采集方案以 image-suite-studio 为主要参考)
② 插件 POST /api/materials 上传(texts + images,已实现)
│ 服务端落库为采集箱商品(stage=collected),异步下载转存图片
③ 双动作(插件内完成):
├─ a. 同步到采集箱(已在 ② 落库)
└─ b. 自动打开商品试算页 {studio}/trial/{product_id}(新标签页)
④ 商品试算页(studio 新页面,操作流水线对齐 web/ 工具台 v1):
├─ 01 商品信息核对/补录(标题、重量、尺寸、描述、采买地址、货号)
├─ 02 价格试算(进货价/净利率/贴单费/物流等级/预留折扣/汇率 → 销售价 ¥/₽、利润、成本)
├─ 03 俄文文案生成(AI 标题/简介/标签,一键回填)
├─ 04 图片与 AI 生图(采集图勾选 → 套图规划 → 一键生成;单张 AI 图生图;导出)
└─ 05 入库与导出(每步自动落库 product.pricing/copyCSV / 组合码导出)
⑤ 上品:人工在 Ozon 卖家后台录入,用 CSV / 组合码批量回填价格与货号
```
---
## 3. V2.1 决策总表(D 系列)
| 编号 | 决策 | 内容 | 理由 |
|---|---|---|---|
| **D1** | 放弃 Ozon API 直传 | `server/api/publish.py``services/publish.py`、店铺/类目代理等发布链路代码**保留但冻结**,不作为主链路;上品方式 = CSV 导出 + 人工后台录入 | ImportProductsV3 字段对齐工作量对个人不可承受;人工上品每品只花几分钟 |
| **D2** | 采集以 image-suite-studio 为主要参考 | **(方案三,已实施)ISS 扩展整体并入主仓 `extensions/collector/`,成为唯一采集工具;扩展新增「上报商品」(POST /api/materials + 自动打开 /trial/{id});extension-v2 原地冻结** | ISS 已跑通三平台采集;单仓维护避免引擎分叉;ISS 独立产品化远期靠构建/配置裁剪,不在代码层分叉 |
| **D3** | 采集完成双动作 | 上传 `/api/materials` 入库(已实现)+ 插件自动打开 `{studio}/trial/{product_id}` | 采集即进入试算流水线,减少手工跳转 |
| **D4** | 商品试算页 studio 化 | 新增 `/trial/:id` 页面,功能与操作流水线对齐 `web/ozonSeller.html`(计价/俄文文案/图片水印/采购地址/登记/CSV/组合码),数据从 localStorage 升级为 products 表落库 | web/ v1 已验证好用,冻结只读;studio 是其 React+antd 版本 |
| **D5** | 图片生成对齐 image-suite-studio | 套图规划(DeepSeek)→ 一键生成(多 provider 模型路由:豆包/通义/RightAPI)→ 服务端水印 → 导出 ZIP;生成图回写 `product_assets(generated)` | 套图能力已在 image-suite-studio 验证;服务端代码整体平移复用 |
| **D6** | 单张 AI 图生图 | 采集图与生成图的每个图片单元格都有「AI 生图」按钮:弹窗输入要求 + 选模型 → 单张生成 | 套图批量生成之外补充精修单图的能力 |
| **D7** | 前端先行 | 先开发试算页前端:服务层按本文 [`api.md`](./api.md) 契约编写;未实现的服务端接口以明确报错提示(不阻断页面其它功能),后再开发服务端 | 先把交互/流水线定型,服务端按前端契约补齐,避免返工 |
| **D8** | 存储沿用 V2 基础设施 | 商品/素材落 PostgreSQL(本地开发 SQLite),图片走 `services/storage.py` 抽象(本地 `data/media/` 兜底,七牛可选) | 不为转向重做存储;D1 冻结后七牛不再是硬依赖 |
---
## 4. 现状盘点(2026-08-26
### 4.1 已实现、V2.1 直接复用
| 能力 | 位置 | 说明 |
|---|---|---|
| Ozon 采集上传落库 | `extension-v2/` + `server/api/collection.py` | `POST /api/materials``/api/materials/bytes`、去重、异步转存 |
| 采集箱 | `studio/src/pages/collection/CollectionPage.tsx` | 列表/筛选/删除/复制 |
| 商品 CRUD | `server/api/products.py` + `studio/src/services/product.ts` | 详情/PATCH 自动保存(raw/pricing/copy JSON 均可存) |
| 计价纯函数 | `studio/src/pricing/pricing.ts` | 从 web/app.js 抄录的核心公式(V2.1 需补尺寸校验等,见 trial-page.md |
| 俄文文案 | `server/api/ai.py` + `studio/src/pages/product/CopyPanel.tsx` | `/api/ai/models``/api/ai/copy`,生成+回填交互完整 |
| 智能修图(万相) | `server/api/image.py` + `studio/src/pages/ai-image/` | `/api/image/edit` 保留,独立页面不动 |
| 汇率 | `server/api/fx.py` + `studio/src/services/fx.ts` | `GET /api/fx` |
| 素材上传(字节) | `server/api/collection.py:204` | `POST /api/materials/bytes`,试算页「上传图片」直接用 |
| 登录鉴权 | `server/api/auth.py` + studio RequireAuth | APP_TOKEN 换 JWT |
### 4.2 V2.1 待开发
| 能力 | 端 | 分册 |
|---|---|---|
| 商品试算页(5 区块) | studio 前端(**Phase A,本次** | [`trial-page.md`](./trial-page.md) |
| 套图规划/一键生成/单张 AI 生图 前端 | studio 前端(**Phase A,本次** | [`image-suite.md`](./image-suite.md) |
| suite 系列服务端接口(plan/generate/suites/image-edit/export | serverPhase B | [`api.md`](./api.md) |
| 采集对齐 image-suite-studio + 自动打开试算页 | extension-v2Phase C | [`collect.md`](./collect.md) |
| 批量 CSV 服务端导出(可选优化) | serverPhase D | [`api.md`](./api.md) §7 |
### 4.3 冻结(不删代码,不再投入)
- Ozon 发布链路:`server/api/publish.py``server/services/publish.py``server/api/categories.py``server/services/ozon_client.py`
- studio 商品编辑页的「属性映射」「发布」区块(页面保留,作为长期能力储备)
- `docs/v2/ozon-publish.md``docs/v2/multi-sku.md` 的直传方案(远期若重启再参考)
- `web/` v1 工具台(继续冻结只读,直到试算页功能对齐后废弃)
---
## 5. 分册索引
| 文档 | 内容 | 什么时候读 |
|---|---|---|
| [`collect.md`](./collect.md) | 采集方案:以 image-suite-studio 为主要参考的引擎架构、三平台路径、采集后双动作、插件改造点 | 做插件(Phase C)时读 |
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
| [`api.md`](./api.md) | 新增/复用的服务端 REST 契约(前端服务层已按此开发) | 前后端联调时读 |
---
## 6. 实施阶段
| 阶段 | 内容 | 状态 |
|---|---|---|
| **Phase A** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 完成 |
| **Phase C** | 采集对接:ISS 扩展并入 `extensions/collector/` + 「上报商品」开关 + 自动打开试算页(方案三,见 collect.md) | ✅ 完成 |
| **Phase B** | 服务端:从 ISS server 平移 planner/generator/prompts/watermark/tasks/api/suite/*、/api/suites/*、/api/export/images、/api/proxy-image),打通试算页一键生成与单张 AI 生图 | 待开工 |
| **Phase D** | 打磨:批量 CSV 服务端化、生成图自动回写素材、水印增强、ISS-only 产品模式构建配置 | 待开工 |
每个阶段结束都可独立运行:Phase A 结束时,试算页的计价/文案/素材上传/入库立即可用(对接已有接口),套图相关按钮点击会提示「服务端接口未实现」。
---
## 7. 与 V2 文档的关系
- V2 的基础设施决策(D1 契约云端化、D3 PostgreSQL、D6 工作台化、D7 鉴权)**全部沿用**。
- V2 的 D 图片方案 B(套图 + 智能修图)**升级落地**:套图引擎不再自研,直接平移 image-suite-studio 的服务端实现。
- V2 的发布链路(`ozon-publish.md`**冻结**`api.md` 中的 `/api/publish``/api/categories` 不再是主链路。
- 若存在分歧,以 `docs/v2.1/` 为准。
---
## 8. 关键风险
| 风险 | 级别 | 对策 |
|---|---|---|
| image-suite-studio 服务端是无状态内存任务表,重启丢任务 | 🟡 低 | 接受(与该项目建设一致):图片落盘 storage 不丢,任务状态丢了重新生成即可;文档明示 |
| 采集图防盗链(Ozon/阿里 CDN)在 studio 页面直接展示可能失败 | 🟡 中 | 素材展示优先 `stored_url`(服务端已转存);Phase B 补 `/api/proxy-image` 兜底 |
| 前端先行的契约与服务端实现不一致导致返工 | 🟡 中 | api.md 即契约真源,服务端按文档实现;字段命名对齐 image-suite-studio 已验证的结构 |
| CSV 人工上品仍是手工活 | 🟢 低 | 组合码 + CSV 已是 web/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
+110
View File
@@ -0,0 +1,110 @@
# V2.1 API 契约(新增 + 复用)
> 状态:前端服务层已按本文开发(Phase A);服务端 Phase B 按 本文实现。
> 通用:鉴权 `Authorization: Bearer <JWT>`studio 走 axios 拦截器自动附带);错误统一 FastAPI `detail`。
---
## 1. 复用的现有接口(Phase A 即可用)
| 接口 | 用途(试算页) |
|---|---|
| `GET /api/products/{id}``PATCH /api/products/{id}` | 商品详情 / 试算页自动保存(raw/pricing/copy/price/weight/... 全字段) |
| `GET /api/products/{id}/assets` | 素材列表(分组/规格/状态/stored_url |
| `GET /api/products?stage&q&page&page_size` | 批量 CSV / 组合码导出的数据源 |
| `POST /api/materials/bytes` | 试算页「上传图片」(FormData:`product_id, group_key=upload, file`)→ `{asset_id, status}` |
| `GET /api/fx` | 汇率 `{rate, source, ts}` |
| `GET /api/ai/models``POST /api/ai/copy` | 俄文文案(模型白名单 / 生成) |
| `POST /api/image/edit` | 智能修图页保留,不在试算页主链路 |
## 2. 套图规划 `POST /api/suite/plan`Phase B
**Req**
```jsonc
{
"product_id": "uuid", // 可选:便于日志与上下文缓存
"texts": [ { "kind": "title|price|params|selling_point|desc|brand|sales|shop",
"content": "…", "pairs": [ { "key": "…", "value": "…" } ] } ],
"sku_variants": ["粉色", "蓝色"],
"image_stats": { "main": 8, "sku": 6, "detail": 12 },
"platform": "ozon",
"requirements": "…|null" // 用户强制要求,最高优先级
}
```
**Res** `{ "summary": "一句话方案说明", "items": [ { "kind": "white_bg", "title": "白底主图", "detail": "…", "prompt_hint": "…", "count": 2, "variant_name": "粉色|null" } ] }`
实现要点:DeepSeek 单行紧凑 JSONkind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总数建议 8-15。同步接口(数秒级)。
## 3. 一键生成 `POST /api/suite/generate`Phase B
**Req**
```jsonc
{
"product_id": "uuid",
"texts": [ ],
"images": [ { "url": "…", "group_key": "main|sku|detail|upload", "variant_name": "粉色|null" } ], // 勾选参考底图
"style_set": 1,
"style_prompt": "…|null", // 用户改写的风格提示词,覆盖后端模板
"requirements": "…|null",
"plan": [ PlanItemcount>0 ],
"platform": "ozon",
"model": "gpt-image-2-vip",
"watermark": { "enabled": true, "type": "image|text", "text": "xiongmaoyx", "opacity": 30 } // 关闭时缺省
}
```
**Res** `{ "suite_id": "…" }`
实现要点:方案按 count 展开逐张 job;内存任务表 + 全局串行队列;逐张 build_prompt(模型家族分册)→ 参考图解析(variant 精确匹配→main 第 1 张;≤2 张)→ 生图 → 水印 → 落 storage;生成图追加 `product_assets(group_key='generated')`
## 4. 任务查询 `GET /api/suites/{id}`Phase B
**Res**
```jsonc
{
"id": "…", "status": "pending|running|done|partial|failed",
"style_set": 1, "platform": "ozon", "lang": "俄文", "ratio": "3:4", "provider": "rightapi",
"total": 8, // 计划总张数(images 逐张追加,过程中 length < total
"images": [ { "type_id": "white_bg", "name": "白底主图-01.png", "url": "/media/suites/…png",
"status": "ok|failed", "error": null } ],
"error": null
}
```
前端 3s 轮询;`done/partial/failed` 终态;后端重启后 404,前端提示重新生成。
## 5. 结果导出 `GET /api/suites/{id}/zip`Phase B
`application/zip` blob(按方案标题命名内部文件)。404/失败返回 `detail` JSON。
## 6. 单张 AI 生图 `POST /api/suite/image-edit`Phase B
**Req**
```jsonc
{
"product_id": "uuid",
"image_url": "…", // stored_url 或 source_url(服务端代下绕防盗链)
"prompt": "把背景换成纯白色摄影棚", // 必填
"model": "nano-banana-2",
"append": true // true:结果追加 product_assets(generated)
}
```
**Res** `{ "url": "/media/suites/….png", "asset_id": "uuid|null" }`
同步接口(单张 5s-5min,前端 axios 超时 120s+;gpt 系列偶发超时由前端重试提示兜底)。
实现要点:复用 §3 的 provider 层与 prompt 家族;参考图 = 传入单图;Ozon 规格(俄文 3:4)。
## 7. 导出与代理(Phase B / D
| 接口 | 方法 | Req | Res |
|---|---|---|---|
| `/api/export/images` | POST | `{ "title": "…", "images": [{ "url", "groupName", "variantName", "key" }] }` | zip blob(分组建文件夹) |
| `/api/proxy-image?url=` | GET | 源站图片 URL | 图片字节(带站点 Referer 代下,绕防盗链) |
| `/api/export/trial-csv` | GET | query`stage?` | csv blob16 列,见 trial-page.md §7Phase D 服务端化,替代前端拼装) |
## 8. 采集相关(Phase C 使用,均已实现)
- `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。
- `GET /api/collected?platform=&itemId=`:采集前查重。
## 9. 冻结接口(不投入,保留代码)
`/api/publish/*``/api/shops/*``/api/categories/*`——Ozon API 直传链路(D1)。
+74
View File
@@ -0,0 +1,74 @@
# V2.1 采集方案(扩展并入主仓 + 上报开关)
> 状态:已实施(Phase C 完成)
> 参考项目:`/Users/joey-xd/sites/seller-store/image-suite-studio`(下称 ISS
> 架构(方案三,D2 决策修订):**ISS 扩展整体并入主仓 `extensions/collector/`**,成为唯一采集工具
> extension-v2 原地冻结、不再维护);扩展新增「上报商品」功能,采集后一键入库主仓后台并自动打开商品试算页。
> 「ISS 独立产品化」远期通过构建/配置裁剪实现(扩展天然独立分发;server 的 suite API 模块不依赖 products),
> 不在代码层分叉——这是「单仓维护」与「独立产品可能」的折中。
---
## 1. 目录与形态
```
ozon-seller-kit/
├── extensions/ # Chrome 插件集合(复数,容纳未来多个插件)
│ └── collector/ ← ISS 扩展并入版(三平台采集 + 套图生图面板 + 商品上报)
├── extension-v1/ # 冻结(旧 1688/淘宝)
├── extension-v2/ # 冻结(旧 Ozon 采集,被 collector 取代)
├── server/ # 唯一后端(8800)
└── studio/ # 前端(试算页等)
```
- 构建:`pnpm -C extensions/collector build``.output/chrome-mv3`Chrome 开发者模式加载 unpacked
- 扩展包名:`ozon-collector-extension`;manifest 名保留「电商套图工作台」
- host_permissions 已加 `127.0.0.1:8800`(上报)与 `8900`(试算页)
## 2. 采集引擎(与 ISS 完全一致,见下表)
| 平台 | 主路径 | 兜底/补充 |
|---|---|---|
| **Ozon** | ① SSR `data-state` widget 白名单 | ② JSON-LD ③ 站内 `entrypoint-api.bx`(两 URL 合并)④ DOM + 模拟滚动 |
| **淘宝/天猫** | ① MAIN world 桥读 `__ICE_APP_CONTEXT__`(不调 mtop 防风控) | ② DOM(前缀匹配类名、详情图排除评价区;desc 故意不采) |
| **1688** | ① MAIN world 桥读 `window.context`deepFindskuProps 全维展开 + skuMapOriginal + 尺寸重量参数) | ② DOM(`#productAttributes` cells、`#detail` |
采集数据结构 ScanResult`{ platform, itemId, url, texts[], images[], breadcrumbs?, stats, warnings, source }`texts: kind/content/pairsimages: groupKey/groupName/variantName/url/index/type)。
## 3. 商品上报(v2.1 新增)
```
快速采集(页内悬浮面板 / Side Panel
│ scanCurrentPage() → ScanResult(面板内可编辑标题/尺寸/重量/描述)
「上报商品」按钮(01 商品信息区块下方;设置里可开关)
│ POST {reportBaseUrl}/api/materials
│ payload = { source: {platform, itemId, url, collectedAt},
│ texts: editedTexts(), // 编辑后文本
│ images: 全量采集图(groupKey/groupName/variantName/url/index/type,
│ refererOrigin: 源页 origin } // 服务端下载源图带 Referer
响应 { product_id } → chrome.tabs.create 打开 {studioBaseUrl}/trial/{product_id}
```
要点:
- **契约**:主仓 `MaterialsRequest`camelCase`source.itemId``groupKey``variantName`)与 ISS ImageMaterial 字段同名,直接映射;响应 `product_id` 用于打开试算页
- **重复上报**:服务端按 `platform + itemId` 复用商品(返回同一 product_id)→ 打开同一试算页
- **鉴权**:本地阶段主仓为宽松模式(未带 token 放行匿名),扩展直接裸调;远期收紧时在扩展 settings 加 token 即可
- **设置项**(面板「服务端设置」弹窗内新增):启用商品上报开关 / OSK 后台地址(默认 `http://127.0.0.1:8800`/ 试算页地址(默认 `http://localhost:8900`
- 手动上传图(ISS 的 upload 组合)本期不上报;试算页内可用「上传图片」补(`/api/materials/bytes`
## 4. 面板能力(保持 ISS 原样)
快速采集、商品信息编辑、图片勾选/上传/下载、AI 智能规划、风格/生图要求/模型选择、一键生图、生成结果导出——全部保留,仍连 ISS 生图 server(`baseUrl`,默认 3300)。
后续 Phase B 把生图服务端平移进主仓后,`baseUrl``reportBaseUrl` 可统一指向主仓。
## 5. 与 ISS 的差异总表
| 维度 | ISS(独立项目) | 本仓 extensions/collector |
|---|---|---|
| 采集引擎 | 三平台完整版 | 相同(整体并入) |
| 生图面板 | 连 ISS server3300 | 同左(Phase B 后可切主仓) |
| 商品入库 | 无 | 「上报商品」→ 主仓 /api/materials + 自动打开试算页 |
| 鉴权 | 无 | 本地宽松模式(远期收紧) |
| 独立产品化 | 本体 | 远期:构建/配置裁剪出 collector + suite API 即可独立 |
+94
View File
@@ -0,0 +1,94 @@
# V2.1 图片生成方案(套图 + 单张 AI 生图)
> 状态:Phase A 前端开发中;Phase B 服务端平移
> 参照:image-suite-studio(下称 ISS`server/services/{planner,generator,prompts,watermark}.py` + 面板 03/04 区块交互
> 原则:生成能力与 ISS **功能一致**,交互移到 studio 试算页并新增「单张 AI 生图」。
---
## 1. 能力总览
| 能力 | 说明 | 状态 |
|---|---|---|
| 采集图片展示与勾选 | 按 main/sku/detail/generated 分组;勾选作为参考底图;默认全选主图+SKU 图 | Phase A 前端 ✅ |
| 手动上传补充参考图 | `POST /api/materials/bytes`,独立 `upload` 分组 | Phase A 前端 ✅(接口已有) |
| 套图方案(默认) | 7 种基础类型各 1 张,数量 0-5 可调 | Phase A 前端 ✅ |
| AI 智能规划 | DeepSeek 按商品信息/图组统计/SKU 规格生成方案(`POST /api/suite/plan`) | 前端 ✅ / 服务端 Phase B |
| 一键生成 | 方案展开为逐张任务,串行队列多模型生成(`POST /api/suite/generate` + 轮询) | 前端 ✅ / 服务端 Phase B |
| 生成图水印 | 服务端 Pillow 合成(图片徽章/文字 + 不透明度,右下角) | 前端选项 ✅ / 服务端 Phase B |
| 生成结果与导出 | 结果网格、失败格错误、导出 ZIP(`GET /api/suites/{id}/zip`) | 前端 ✅ / 服务端 Phase B |
| 下载采集图片 | 勾选图打包 ZIP(`POST /api/export/images`,防盗链代理下载) | 前端 ✅ / 服务端 Phase B |
| **单张 AI 生图(V2.1 新增)** | 每张采集图/生成图上「AI 生图」按钮 → 弹窗:要求文本域 + 模型选择 → 单张生成 | 前端 ✅ / 服务端 Phase B |
| 生成图回写商品 | 生成完成的图追加为 `product_assets(generated)` | Phase B/D |
## 2. 目标平台与规格
试算页固定目标平台 **Ozon**:俄文图内文案、3:4(1536×2048)。
ISS 的 wb/cn 选项不做;`platform` 字段仍随请求下发,服务端按平台决定文案语言与比例,保留扩展性。)
## 3. 出图方案(Plan
- **类型白名单(10 种)**white_bg 白底主图 / key_features 核心卖点图 / selling_pt 卖点图 / material 材质图 / lifestyle 场景展示图 / multi_scene 多场景拼图 / ecommerce_detail 电商详情图 / size_chart 尺寸标注图 / sku_collection SKU合集 / custom 创意图。
- **默认方案**:前 7 种各 1 张;行点击切换 0↔1,± 调 0-5,「全部方案」批量开关。
- **AI 规划**:请求 `{product_id, texts, sku_variants, image_stats, platform, requirements}`DeepSeek 输出单行 JSON `{summary, items:[{kind,title,detail,prompt_hint,count,variant_name}]}`;服务端清洗(kind 白名单、count 钳 0-3、幻觉 SKU 丢弃绑定、截断修复)。规则:每个带图 SKU 1 张 white_bg 且绑定 variant_name;总数 8-15。
- **规划并生成**:勾选后规划完成直接进入生成。
- SKU 规格来源:`product_assets``group_key='sku'``variant_name` 去重。
## 4. 生成(Generate
```
POST /api/suite/generate
{ product_id, texts, images:[{url, group_key, variant_name}], // 勾选的参考底图
style_set, style_prompt, requirements, plan, platform:'ozon', model, watermark }
→ { suite_id }
```
- **texts 组装**(前端已实现,source = product.raw + 包装字段覆盖):
`title(title_zh||title) / price / brand / sales / shop / params(含尺寸、重量覆盖回参数表) / selling_point / desc`
- **参考图解析**(服务端,ISS 逻辑):variant_name 精确匹配 SKU 图 → 回退 main 组第 1 张;material 偏好第 2 张;最多 2 张;本地 storage 直读,远程 URL 带站点 Referer 下载,转 data-URI 进请求。
- **提示词**:按模型家族分册(alibaba 主体参考语义 / gpt edits 保真语义 / google 主体保持),共用 10 种图类型 builder + 商品上下文(参数表提炼卖点)+ 5 套风格 + `requirements` 置顶强制约束 + 图内文案按语言规范(俄文 ≤4 词)。
- **任务模型**:进程内内存任务表 + `asyncio.Lock` 全局串行队列(防中转限流);逐张生成 → Pillow 水印 → 按 PNG 魔数定扩展名落 `storage`。**重启丢任务、图片不丢**(与 ISS 一致,接受)。
- **前端轮询**`GET /api/suites/{id}` 每 3s`done/partial/failed` 终态;连续 3 次失败停止跟踪;超预算(5min/张+10min)提示。
## 5. 模型路由(与 ISS 一致,服务端 MODEL_PROVIDERS 表)
| Provider | 模型 | 调用 |
|---|---|---|
| doubao(火山方舟) | doubao-seedream-4-5-251128 | `images/generations` 同步,image 传 data-URI |
| tongyiDashScope | qwen-image-3.0-pro / wan2.7-image-pro / wan2.6-image / wan2.6-t2i | qwen 同步;wan 异步 task 轮询;t2i 纯文生图 |
| rightapi(中转) | gpt-image-2 / gpt-image-2-vip / nano-banana(-2/-2-lite/-pro) | `POST /v1/images/generations` async + 任务轮询 + 退避重试 |
前端下拉 10 个模型带中文特点说明,默认 `gpt-image-2-vip`。密钥:`ARK_API_KEY` / `DASHSCOPE_API_KEY` / `RIGHTAPI_API_KEY` / `DEEPSEEK_API_KEY`.envPhase B 接入本仓 settings)。
## 6. 风格与要求
- 5 套风格(北欧极简/清新明亮/高级感深色/暖调生活/纯净棚拍)+ 每套默认提示词;**风格提示词可改写**(按风格 id 记忆,切风格不丢,可恢复默认)。
- 「生图要求」文本域:最高优先级强制约束,服务端置于 prompt 最前并声明覆盖一切冲突指令。
- 水印选项(Popover):开关 + 类型(图片徽章/文字)+ 水印文字 + 不透明度(默认关、`xiongmaoyx`、30%);随生成请求下发,关闭时不带字段;位置右下角;重新生成后生效。
## 7. 单张 AI 生图(V2.1 新增)
交互(前端已实现,`AiImageGenModal.tsx`):
```
采集图/生成图单元格 → 「AI生图」按钮
→ 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 生成按钮
→ POST /api/suite/image-edit { product_id, image_url, prompt, model, append: true }
→ 返回 { url },弹窗内展示结果,可下载/打开原图
→ 服务端把结果追加为 product_assets(group_key='generated', variant_name=null)
```
- 与「智能修图」页(`/ai-image`,wanx 注解编辑)并存:本入口是**轻量单张再生成**(选模型 + 一句话要求),不做标注/遮罩。
- 服务端实现 = 统一模型路由的单张调用(复用 §5 provider 层 + 参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。
## 8. 展示与防盗链
- 素材展示优先 `stored_url`(服务端已转存,本地 `/media/...` 或七牛),缺失时回退 `source_url`
源站防盗链导致破图时走 `GET /api/proxy-image?url=`Phase B 提供,ISS 同款)。
- 预览用 antd `Image.PreviewGroup` 画廊( ← → / Esc 与 ISS 行为一致)。
## 9. 导出
- **导出 ZIP**(生成结果):`GET /api/suites/{id}/zip`,文件名 `cleanFilename(标题).zip`
- **下载采集图片**`POST /api/export/images {title, images:[{url, groupName, variantName, key}]}`,按分组建文件夹打包;
- 前端经 blob + `<a download>` 落盘(非扩展环境没有 chrome.downloads)。
+161
View File
@@ -0,0 +1,161 @@
# V2.1 商品试算页方案(studio /trial/:id
> 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md)
> 参照:`web/ozonSeller.html` + `web/js/app.js`v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。
> 前端代码:`studio/src/pages/trial/`
---
## 1. 定位与入口
商品试算页是 V2.1 的**主工作流页面**:采集落库后的一切加工都在这里完成——
核对商品信息 → 价格试算 → 俄文文案 → 图片/AI 生图 → 入库 → 导出 CSV/组合码 → 人工上 Ozon 后台。
入口:
1. **插件采集后自动打开**Phase C):`/trial/{product_id}` 新标签页;
2. **采集箱「试算」按钮**(Phase A 已加):每行操作列;
3. URL 直达 / 从商品编辑页切换。
商品编辑页(`/product/:id`)保留,定位降级为「Ozon 结构化字段的长期储备」;试算页不依赖它。
## 2. 页面结构
```
┌──────────────────────────────────────────────────────┬────────────┐
│ 标题(中文/俄文) · stage 标签 · 保存状态 · 来源链接 · 去编辑页 │ 区域导航 │
├──────────────────────────────────────────────────────┤ (锚点滚动) │
│ 01 商品信息 标题/采集价/品牌/销量/店铺/货号/采买地址/ │ │
│ 参数表/描述(可编辑,onBlur 落库) │ │
│ 02 价格试算 重量/尺寸/进货价/净利率/贴单费/物流等级/划线价 │ │
│ 倍数/汇率 → 结果卡(物流费→实收价/净利润→完全 │ │
│ 成本/总抽成→销售价¥/₽/划线价) + 告警 │ │
│ 03 俄文文案 商品资料 → 模型选择 → 生成标题/简介/标签 → 回填 │ │
│ 04 图片与AI生图 采集图片(分组勾选/上传/AI生图) + 出图方案(规划/ │ │
│ 风格/要求/模型/一键生成+进度) + 生成结果(导出) │ → image-suite.md │
│ 05 入库与导出 入库状态 + 导出本商品CSV + 批量导出CSV + 组合码 │ │
└──────────────────────────────────────────────────────┴────────────┘
```
## 3. 01 商品信息
编辑即落库(onBlur/onChange → `PATCH /api/products/{id}`,与编辑页一致的自动保存模式)。
| 字段 | 落库位置 | 说明 |
|---|---|---|
| 标题(中文) | `raw.title_zh`(回退展示 `raw.title` | 采集原标题写入 `raw.title`,人工整理后的中文名写 `title_zh` |
| 俄文标题 | `name` | 文案生成回填,也可手改 |
| 采集价 / 品牌 / 销量 / 店铺 | `raw.price` / `raw.brand` / `raw.sales` / `raw.shop` | 只读展示 |
| 重量 g | `weight``weight_unit='g'` | 输入在 **02 价格试算左列顶部**(计价强相关);采集已自动 kg→g;变化即重算落库 |
| 长×宽×高 cm | `depth/width/height``dimension_unit='mm'`,cm×10 落库) | 同上,计价与尺寸校验用 |
| 货号(SKU | `offer_id` | 对齐 v1「型号-后缀」,v2.1 用单字段;CSV/组合码的主键 |
| 采买地址 | `raw.purchase_url` | 1688/淘宝采集时默认取 `source_url`;可改;导出 CSV 列 |
| 参数表 | `raw.params``[{key,value}]`) | 只读折叠展示;供文案/生图上下文 |
| 描述 | `raw.desc` | 可编辑;供文案/生图上下文 |
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认1000-500)、
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
**物流费**`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
| 等级 | 条件 | 公式 | 规则文案 |
|---|---|---|---|
| low | ≤500g | `3.12 + 0.026×重量` | low3.12 + 0.026×重量 |
| low | >500g | `23.92 + 0.01768×重量` | low23.92 + 0.01768×重量 |
| high2 | ≤5000g | `22.88 + 0.026×重量` | 高222.88 + 0.026×重量 |
| high2 | >5000g | `64.48 + 0.024×重量` | 高264.48 + 0.024×重量 |
| high | ≤2000g | `16.64 + 0.026×重量` | 普通:16.64 + 0.026×重量 |
| high | >2000g | `37.44 + 0.01768×重量` | 普通:37.44 + 0.01768×重量 |
最后 `+ 贴单费`(规则文案追加「 + 通递价」)。
**计价链**(与 web/app.js:1035-1053 逐行一致):
```
实收价 = 进货价 × (1 + 净利率%)
净利润 = 进货价 × 净利率%
净得率 = low→0.845,其余→0.785
销售价¥ = (实收价 + 物流费) / 净得率
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
完全成本 = 进货价 + 物流费 + 平台总抽成
预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
```
**校验**v2.1 新增移植到 `pricing.ts``validateDimensions` / `validateLogisticsLevelCny` / `validatePriceRange`,注意等级/区间建议用的销售价单位是 **CNY**,与 v1 一致):
| 校验 | 规则 | 表现 |
|---|---|---|
| 尺寸硬校验 | low≤500g:三边和≤90 且最长边≤60low>500g:≤150/≤60high≤2000g:≤150/≤60high>2000g:≤250/≤150high2 按 high 档执行,v1 即如此) | 红色错误,结果卡显示 `--`,不落计价结果 |
| 物流等级建议 | 销售价>140¥ 且 low → 建议高等级;<135¥ 且非 low → 建议低等级 | 橙色提示 |
| 价格区间 | 销售价 ∈ [135,140]¥ → 汇率波动风险 | 橙色提示 |
**落库**:参数或结果任一变化即重算并保存(对齐编辑页 PriceInfoPanel 模式):
```jsonc
// product.pricingPriceInfoPanel 字段的超集,向后兼容)
{
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3,
"lineMultiplier": 100, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
"logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63,
"fullCommission": 15.02, "totalCost": 67.14,
"sellingPriceCny": 77.51, "linePriceCny": 155.02,
"sellingPriceRub": 891.4, "linePriceRub": 1782.7,
"calculatedAt": "2026-08-26T12:00:00Z"
}
// 同时回填:price=销售价CNYold_price=划线价CNYcurrency_code=CNYfx_rate=汇率
```
## 5. 03 俄文文案
直接复用编辑页 CopyPanel`studio/src/pages/product/CopyPanel.tsx`)与服务端 `/api/ai/models``/api/ai/copy`
- 输入区预填 `raw.title_zh + raw.params + raw.desc + 卖点`
- 生成 2 个推荐标题(俄+中对照)、俄文简介、标签芯片;
- 「回填」标题写 `name + raw.title_zh`,简介写 `description`,结果另存 `product.copy`(后续服务端可加,前端先回填字段)。
## 6. 04 图片与 AI 生图
详见 [`image-suite.md`](./image-suite.md)。前端(Phase A)已实现完整交互:
采集图片分组展示与勾选、上传(`POST /api/materials/bytes`,已可用)、每张图「AI 生图」弹窗、
套图方案(默认/AI 规划/数量微调)、5 套风格 + 可编辑风格提示词、生图要求、10 个生图模型、
一键生成 + 进度轮询 + 结果网格 + 导出 ZIP、下载采集图片。
套图相关服务端接口 Phase B 提供,前端先行按 api.md 契约调用。
## 7. 05 入库与导出
**入库**:无独立「录入」按钮——01/02/03 区块的每次编辑都自动落库(v1 的 localStorage 登记表 → products 表)。
本区块显示入库状态(计价时间 `pricing.calculatedAt`、货号、来源)。
**CSV(对齐 web/app.js:1376-1424 的 16 列,UTF-8 BOM + RFC 转义)**
```
货号(sku=offer_id)、商品名(title_zh)、进货价、物流费、平台总抽成、实收价、完全成本、
销售价、净利润、净利率、卢布销价、重量(g)、尺寸(cm L×W×H)、状态(stage 中文)、
Ozon地址(https://www.ozon.ru/product/<sku>)、采买地址(raw.purchase_url)
```
- **导出本商品 CSV**:当前商品一行,立即下载;
- **批量导出 CSV**:拉取商品列表(有 `pricing.calculatedAt` 的)逐个取详情拼装(Phase A 前端拼装,Phase D 可换 `GET /api/export/trial-csv`);
- **组合码**`货号 预留₽价(两位小数)` 每行一条(无预留₽回退销售价₽),弹窗展示 + 一键复制,用于 Ozon 后台批量改价(对齐 v1「上品组合码」)。
## 8. 前端文件清单(Phase A
| 文件 | 职责 |
|---|---|
| `studio/src/pages/trial/TrialPage.tsx` | 页面骨架:加载商品+素材、自动保存、区块导航 |
| `studio/src/pages/trial/TrialInfoPanel.tsx` | 01 商品信息 |
| `studio/src/pages/trial/TrialPricingPanel.tsx` | 02 价格试算 |
| `studio/src/pages/product/CopyPanel.tsx` | 03 俄文文案(复用,不新建) |
| `studio/src/pages/trial/TrialSuitePanel.tsx` | 04 图片与 AI 生图(素材/方案/生成/结果) |
| `studio/src/pages/trial/AiImageGenModal.tsx` | 单张 AI 生图弹窗 |
| `studio/src/pages/trial/TrialExportPanel.tsx` | 05 入库与导出 |
| `studio/src/services/suite.ts` | 套图/生图/导出 服务层(按 api.md 契约) |
| `studio/src/pricing/pricing.ts` | 追加 validateDimensions 等三个校验 + 规则文案 |
| `studio/src/utils/file.ts` | CSV 转义 / blob 下载 / 文件名清理 |
| `studio/src/router/index.tsx` + `layouts/menuConfig.tsx` + `CollectionPage.tsx` | 路由 `/trial/:id`、页头信息、采集箱「试算」入口 |
@@ -0,0 +1,66 @@
import { generateSuite, getSuite, planSuite } from '../src/api/client';
import { reportProduct } from '../src/api/report';
// Background Service Worker —— 唯一出网口(生成 / 规划 / 轮询任务,绕 CORS)
export default defineBackground(() => {
console.log('[电商套图工作台] background started');
// 点击扩展图标 → 开关当前商品页的悬浮面板(页面无 content script 时忽略)
chrome.action.onClicked.addListener(async (tab) => {
if (tab.id == null) return;
try {
await chrome.tabs.sendMessage(tab.id, { action: 'toggle-suite-panel' });
} catch {
// 非四站点页面,未注入面板
}
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'generateSuite') {
generateSuite(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (msg?.action === 'planSuite') {
planSuite(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (msg?.action === 'getSuite') {
getSuite(msg.baseUrl, msg.token, msg.suiteId)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
// 商品上报:采集结果 POST 到 ozon-seller-kit 后台 /api/materials
if (msg?.action === 'reportProduct') {
reportProduct(msg.reportBaseUrl, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API
if (msg?.action === 'fetchText') {
const headers: Record<string, string> = {};
if (msg.referer) headers['Referer'] = msg.referer;
fetch(msg.url, {
headers,
credentials: msg.credentials ? 'include' : 'omit',
})
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
sendResponse({ ok: true, text: await res.text() });
})
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
@@ -0,0 +1,36 @@
// MAIN world 桥 —— 跑在页面主世界,读取页面 JS 变量(isolated world 读不到)。
// 协议(对齐竞品 inject.js 模式):
// isolated → MAIN: {type:'sc-bridge-req', requestId, keys: [...]} keys 含 '*' 时返回诊断键列表
// MAIN → isolated: {type:'sc-bridge-res', requestId, payload: {key: value}}
// MAIN 侧零业务逻辑:只读白名单键、JSON 序列化过滤后回传,不注入任何页面行为。
export default defineContentScript({
matches: [
'https://*.ozon.ru/*', 'https://*.ozon.kz/*', 'https://*.ozon.by/*',
'https://detail.1688.com/*',
'https://item.taobao.com/*', 'https://detail.tmall.com/*',
],
world: 'MAIN',
main() {
window.addEventListener('message', (ev: MessageEvent) => {
if (ev.source !== window) return;
const d = ev.data as { type?: string; requestId?: string; keys?: string[] } | null;
if (!d || d.type !== 'sc-bridge-req' || !d.requestId || !Array.isArray(d.keys)) return;
const payload: Record<string, unknown> = {};
if (d.keys.includes('*')) {
// 诊断模式:列出页面上可能有数据的全局键
payload['__sc_window_keys__'] = Object.keys(window).filter(k =>
/^(__|_)?[A-Za-z]/.test(k) && /(context|rawData|ICE|sku|item|g_config|DATA|state)/i.test(k)
);
}
for (const k of d.keys) {
if (k === '*') continue;
try {
const v = (window as unknown as Record<string, unknown>)[k];
if (v !== undefined) payload[k] = JSON.parse(JSON.stringify(v)); // 过滤函数/循环引用
} catch { /* 不可序列化的跳过 */ }
}
window.postMessage({ type: 'sc-bridge-res', requestId: d.requestId, payload }, '*');
});
},
});
@@ -0,0 +1,26 @@
// Content Script —— 注入四个平台的商品页,暴露采集入口
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
// Ozon
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
// 1688
'https://detail.1688.com/*',
// 淘宝 / 天猫
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
main() {
console.log('[电商套图工作台] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
};
console.log('[电商套图工作台] 就绪。Console 可测: await window.__SuiteCollector.scan()');
},
});
@@ -0,0 +1,182 @@
// Panel Content Script —— 商品页右下角悬浮按钮 + 页内悬浮面板
//
// 面板 = iframe 加载插件内置 sidepanel.html(扩展页面在 iframe 里仍有 chrome.* 权限,
// 采集/生成/导出逻辑零改动);按钮与面板容器渲染在独立 Shadow DOM 中,
// 不受商品页全局 CSS 影响,面板悬浮覆盖页面、不挤压原页面布局。
import { matchProfile } from '../src/profiles/index';
/** 面板内 App.tsx → 宿主页的收起消息 */
const PANEL_CLOSE_MSG = 'sc-panel-close';
const STYLES = `
:host { all: initial; }
.fab {
position: fixed;
right: 24px;
bottom: 24px;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font: 700 20px/1 -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #fff;
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
box-shadow: 0 4px 16px rgba(109, 40, 217, 0.45);
z-index: 3;
transition: transform 0.15s ease;
}
.fab:hover { transform: scale(1.08); }
.fab.hidden { display: none; }
/* 收起按钮:骑在面板左上边缘(一半在面板外)。必须在 iframe 外渲染——
iframe 裁剪内容,iframe 内的元素永远溢不出面板边界 */
.panel-close {
position: fixed;
top: 20px;
right: min(880px, 100vw); /* 面板左边缘 = 视口右沿 - 面板宽度 */
transform: translateX(50%);
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff;
color: #555;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
z-index: 4;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.15s ease;
}
.panel-close.visible { opacity: 1; pointer-events: auto; }
.panel-close:hover { color: #6d28d9; border-color: #6d28d9; transform: translateX(50%) scale(1.08); }
.panel {
position: fixed;
top: 0;
bottom: 0;
right: 0;
width: min(880px, 100vw);
border-radius: 14px 0 0 14px;
overflow: hidden;
background: #fff;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 2;
pointer-events: none;
}
.panel.open { transform: translateX(0); pointer-events: auto; }
.panel iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
}
`;
export default defineContentScript({
matches: [
// Ozon
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
// 1688
'https://detail.1688.com/*',
// 淘宝 / 天猫
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'suite-studio-panel',
position: 'overlay',
anchor: 'body',
alignment: 'bottom-right',
zIndex: 2147483646,
css: STYLES,
isolateEvents: true,
onMount(container) {
const fab = document.createElement('button');
fab.className = 'fab hidden';
fab.title = '电商套图工作台';
fab.textContent = '套';
const panel = document.createElement('div');
panel.className = 'panel';
// iframe 懒加载:首次展开才设 src,避免每个商品页都加载整个面板应用
const iframe = document.createElement('iframe');
iframe.title = '电商套图工作台';
panel.append(iframe);
// 外置收起按钮(骑在面板左上边缘,一半在面板外)
const closeBtn = document.createElement('button');
closeBtn.className = 'panel-close';
closeBtn.title = '收起面板(Esc';
closeBtn.innerHTML =
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
'<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
container.append(fab, panel, closeBtn);
const open = () => {
if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html');
panel.classList.add('open');
closeBtn.classList.add('visible');
fab.classList.add('hidden');
// 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用
iframe.focus();
};
const close = () => {
panel.classList.remove('open');
closeBtn.classList.remove('visible');
if (isProductPage()) fab.classList.remove('hidden');
};
fab.addEventListener('click', open);
closeBtn.addEventListener('click', close);
// 面板内 App(Esc)→ 收起;✕ 按钮已外置到宿主层(closeBtn)
window.addEventListener('message', (e) => {
if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close();
});
// 工具栏图标点击 → 开关面板
chrome.runtime.onMessage.addListener((msg: any) => {
if (msg?.action === 'toggle-suite-panel') {
panel.classList.contains('open') ? close() : open();
}
});
// 仅商品详情页显示按钮;站内软导航后重判(WXT 内置事件,自动拦截 pushState/replaceState/popState
const refresh = () => {
if (isProductPage()) {
if (!panel.classList.contains('open')) fab.classList.remove('hidden');
} else {
fab.classList.add('hidden');
close();
}
};
ctx.addEventListener(window, 'wxt:locationchange', refresh);
refresh();
return { open, close };
},
});
ui.mount();
},
});
/** 是否为四站点支持的商品详情页(与采集 profile 一致) */
function isProductPage(): boolean {
return matchProfile(location.href) !== null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电商套图工作台</title>
<style>
:root {
--bg: #f5f5f5; /* 页面背景(中性灰) */
--card: #ffffff;
--card-soft: #fafafa;
--border: #f0f0f0;
--border-strong: #e0e0e0;
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
--primary-hover: #7c3aed;
--primary-ring: rgba(139, 92, 246, 0.12);
--primary-soft: #a78bfa; /* 主题色同色系偏淡(未勾选描边/✓) */
--green: #52c41a;
--red: #ff4d4f;
--warn-bg: #fffbe6;
--warn-border: #ffe58f;
--warn-text: #8c6d1f;
--text: #262626;
--text-2: #8c8c8c;
}
html, body, #root {
min-width: 860px;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
}
* { box-sizing: border-box; }
/* ── 页面骨架 ── */
.page { padding: 16px 18px 22px; }
/* 采集区两列等高:左右卡片拉伸到同一高度 */
.two-col { display: flex; gap: 14px; align-items: stretch; margin-bottom: 14px; }
.two-col .section { flex: 1; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
.two-col .section .section-head { flex-shrink: 0; }
/* 图片列表占满 section 除标题外的剩余高度;min-height:0 是 flex 子项内滚动的关键 */
.img-groups { flex: 1 1 auto; min-height: 0; overflow-y: auto; max-height: 78vh; }
/* 采集图片区:section 自身去掉左右 padding,标题行自持 padding
图片区左侧对齐标题,右侧只留窄缝给滚动条(滚动条贴卡片内缘,图片与滚动条之间有小间距) */
.section-images { padding: 14px 0 !important; }
.section-images .section-head { padding: 0 16px; }
.section-images .img-groups { padding: 2px 8px 0 16px; }
.section-images .empty { margin: 0 16px; }
.img-export-bar {
display: flex; align-items: center; gap: 10px;
margin: 10px 16px 2px; padding-top: 10px;
border-top: 1px solid var(--border);
}
.img-export-bar .hint { font-size: 12px; color: var(--text-2); }
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
/* ── 顶部 ── */
.topbar {
display: flex; align-items: center; gap: 11px;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.logo {
width: 38px; height: 38px; border-radius: 9px;
background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 19px; font-weight: 700;
}
.topbar h1 { font-size: 17px; margin: 0; font-weight: 700; }
.topbar .sub { font-size: 12px; color: var(--text-2); margin-top: 1px; }
.topbar .spacer { flex: 1; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-strong);
font-size: 14px; cursor: pointer; user-select: none;
background: #fff; color: var(--text);
transition: all .15s;
}
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn-primary {
background: var(--primary); border-color: var(--primary); color: #fff;
font-weight: 600;
}
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
/* AI 智能规划:深靛紫渐变(智慧/深度感) */
.btn-ai {
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
border: none; color: #fff; font-weight: 600;
box-shadow: 0 2px 10px rgba(88, 60, 210, 0.35);
}
.btn-ai:hover:not([disabled]) {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #fff; box-shadow: 0 3px 14px rgba(88, 60, 210, 0.45);
}
/* 三个主操作按钮统一宽度 */
.btn-main { width: 160px; }
/* 「规划并生成」复选框 */
.auto-chk {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(--text-2); cursor: pointer; user-select: none;
}
.auto-chk input { accent-color: var(--primary); width: 14px; height: 14px; cursor: pointer; }
.auto-chk:hover { color: var(--text); }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.icon-btn {
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border-strong);
background: #fff; cursor: pointer; color: var(--text-2);
display: inline-flex; align-items: center; justify-content: center;
}
.icon-btn:hover { color: var(--primary); border-color: var(--primary); }
/* ── 编号步骤卡片 ── */
.section {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 14px 16px; margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.section-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 12px; }
.section-no {
font-size: 20px; font-weight: 800; color: var(--primary);
font-variant-numeric: tabular-nums; line-height: 1;
}
.section-title { font-size: 15px; font-weight: 700; }
.section-extra { margin-left: auto; font-size: 12px; color: var(--text-2); }
/* ── 字段 ── */
.field { margin-bottom: 10px; }
.field label { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 4px; }
.field input, .field textarea {
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong);
border-radius: 6px; font-size: 14px; font-family: inherit; line-height: 1.5;
background: var(--card-soft); color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus, .field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring);
background: #fff;
}
.kv-table {
width: 100%; border-collapse: collapse; font-size: 13px;
background: var(--card-soft); border-radius: 6px; overflow: hidden;
}
.kv-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.kv-table tr:last-child td { border-bottom: none; }
.kv-table td.k { color: var(--text-2); white-space: nowrap; width: 1%; padding-right: 16px; }
/* ── 药丸选择 ── */
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
display: inline-flex; align-items: center; justify-content: center;
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--border-strong);
background: #fff; font-size: 13px; cursor: pointer; color: var(--text-2);
user-select: none; transition: all .15s; line-height: 1.4;
}
.pill:hover { border-color: var(--primary); color: var(--primary); }
.pill.on {
background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
}
/* ── 图片网格 ── */
.group-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 9px; }
.group-head .name { font-size: 13px; font-weight: 600; color: var(--text-2); }
.group-head .count {
font-size: 12px; color: var(--text-2); background: var(--card-soft);
border: 1px solid var(--border); border-radius: 999px; padding: 0 8px;
}
.group-head .mini-check { margin-left: auto; font-size: 12px; color: var(--primary); cursor: pointer; user-select: none; }
.group-head .mini-check:hover { text-decoration: underline; }
.img-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.img-cell {
position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden;
border: 2px solid transparent; cursor: zoom-in; background: var(--card-soft);
}
.img-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.img-cell.on { border-color: var(--primary); }
.img-cell .tick {
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px;
border-radius: 50%; border: 1.5px solid var(--primary-soft);
background: rgba(255, 255, 255, 0.55); display: flex; align-items: center; justify-content: center;
color: var(--primary-soft); font-size: 11px; transition: all .15s; cursor: pointer;
}
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); color: #fff; }
.img-cell .variant {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 生成结果(整行,6 列)── */
.result-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.result-cell { position: relative; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.result-cell img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.result-cell .cap {
font-size: 11.5px; text-align: center; padding: 3px 0;
background: var(--card-soft); color: var(--text-2);
border-top: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.result-cell.fail { opacity: .55; }
.result-cell .fail-tag {
position: absolute; top: 5px; right: 5px; font-size: 11px;
background: var(--red); color: #fff; border-radius: 4px; padding: 0 5px;
}
/* ── 目标平台切换条 ── */
.platform-bar {
display: flex; align-items: center; gap: 10px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 16px; margin-bottom: 14px;
}
.platform-label { font-size: 13px; font-weight: 700; }
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
/* 平台切换:Button.Group 形式,选中态用低饱和灰绿(不抢主题色) */
.seg-group { display: inline-flex; }
.seg-btn {
padding: 7px 20px; font-size: 13.5px; font-family: inherit;
min-width: 120px; text-align: center; /* 选中加粗会让文字变宽,固定宽度消除跳动 */
background: #fff; border: 1px solid var(--border-strong); border-left-width: 0;
color: var(--text-2); cursor: pointer; user-select: none; transition: all .15s;
}
.seg-group .seg-btn:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
.seg-group .seg-btn:last-child { border-radius: 0 8px 8px 0; }
.seg-btn:hover { color: var(--text); background: var(--card-soft); }
.seg-btn.on {
background: #eef0eb; border-color: #c9cec6; color: #3f453c; font-weight: 700;
}
.seg-group .seg-btn.on + .seg-btn { border-left-color: #c9cec6; }
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 min-width 消除抖动 */
.platform-bar .pill { min-width: 108px; text-align: center; }
/* ── 图片放大预览(画廊)── */
.lightbox {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.82);
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 12px; cursor: zoom-out;
}
.lightbox img {
max-width: 88%; max-height: 82%;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-nav {
position: absolute; top: 50%; transform: translateY(-50%);
width: 40px; height: 64px; border: none; border-radius: 8px;
background: rgba(255, 255, 255, 0.12); color: #fff;
font-size: 30px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s; user-select: none;
}
.lightbox-nav:hover { background: rgba(255, 255, 255, 0.28); }
.lightbox-nav.prev { left: 14px; }
.lightbox-nav.next { right: 14px; }
.lightbox-counter {
position: absolute; top: 14px; right: 16px;
background: rgba(0, 0, 0, 0.5); color: #fff;
font-size: 13px; padding: 3px 10px; border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
/* ── 出图方案 ── */
.plan-list { display: flex; flex-direction: column; gap: 4px; }
.plan-row {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--card-soft); cursor: pointer;
}
.plan-row:hover { border-color: var(--primary); }
.plan-row.off { opacity: .45; }
.plan-all-toggle {
display: flex; align-items: center; gap: 10px;
margin: 8px 2px 2px; padding-top: 6px;
border-top: 1px dashed var(--border);
}
.plan-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.variant-chip {
flex-shrink: 0; font-size: 11.5px; padding: 0 8px; line-height: 1.8;
border-radius: 999px; background: #f3efff; border: 1px solid #ddd3fa; color: #6d28d9;
}
.plan-detail {
font-size: 12px; color: var(--text-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* 构图提示(prompt_hint):生图要求的落地处,用主题蓝区分 */
.plan-detail.plan-hint { color: #4f6bed; }
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
.step-btn {
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
border-radius: 5px; cursor: pointer; font-size: 14px; line-height: 1; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
}
.step-btn:hover:not([disabled]) { border-color: var(--primary); color: var(--primary); }
.step-btn[disabled] { opacity: .35; cursor: not-allowed; }
.step-num {
min-width: 28px; text-align: center; font-size: 13.5px; font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ai-tag {
background: var(--primary); color: #fff; font-size: 11px;
border-radius: 4px; padding: 1px 6px; margin-right: 4px;
}
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
/* ── 模型下拉选项 ── */
.model-opt-name { font-size: 13.5px; font-weight: 600; color: var(--text); }
.model-opt-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; }
.ok-chip {
display: inline-flex; align-items: center; gap: 5px;
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
border-radius: 6px; padding: 4px 9px; font-size: 12.5px;
}
.warn-box {
background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
border-radius: 6px; padding: 7px 10px; font-size: 12.5px; margin-top: 6px; line-height: 1.6;
}
.empty {
text-align: center; color: var(--text-2); font-size: 13px;
padding: 22px 0; background: var(--card-soft); border-radius: 6px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ozon-collector-extension",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"antd": "^6.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3",
"wxt": "^0.19.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+4411
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
spawn-sync: true
@@ -0,0 +1,71 @@
/**
* 1688 提取器离线验证 —— 用 reference/1688.html 快照跑真实提取逻辑。
*
* 用法:node scripts/verify-1688.mjs [快照路径]
* 依赖 esbuild 打包 TS 提取器(node_modules 里有)。
*/
import { readFileSync, readdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const htmlPath = process.argv[2] ?? '/Users/joey/sites/seller-store/ozon-seller-kit/reference/1688.html';
// 1. 打包提取器(纯函数无 DOM 依赖);pnpm 布局下 esbuild bin 可能不在 .bin,动态查找
function findEsbuild() {
const candidates = [join(root, 'node_modules/.bin/esbuild')];
try {
const pnpmDir = join(root, 'node_modules/.pnpm');
for (const d of readdirSync(pnpmDir)) {
if (d.startsWith('esbuild@')) {
candidates.push(join(pnpmDir, d, 'node_modules/esbuild/bin/esbuild'));
}
}
} catch { /* ignore */ }
candidates.push('esbuild'); // 全局兜底
for (const c of candidates) {
try { execSync(`${c} --version`, { stdio: 'pipe' }); return c; } catch { /* try next */ }
}
throw new Error('找不到可用的 esbuild');
}
const outFile = '/tmp/1688-state.bundle.mjs';
execSync(`${JSON.stringify(findEsbuild())} src/collector/1688-state.ts --bundle --format=esm --outfile=${outFile}`, { cwd: root });
const { extract1688State } = await import(`file://${outFile}`);
// 2. 从快照提取 script#3 并在 window 垫片里求值(还原 window.context
const html = readFileSync(htmlPath, 'utf8');
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(m => m[1]);
const ctxScript = scripts.find(s => s.includes('window.context')) ?? scripts[3];
const windowShim = {};
new Function('window', 'document', 'location', ctxScript)(
windowShim, { querySelector: () => null }, { hostname: 'detail.1688.com' },
);
const context = windowShim.context;
if (!context) {
console.error('✗ window.context 求值失败');
process.exit(1);
}
console.log(`✓ window.context 就绪(keys: ${Object.keys(context).join(', ')}`);
// 3. 跑提取器并断言
const st = extract1688State(context);
const assert = (cond, msg) => { if (!cond) { console.error(`${msg}`); process.exit(1); } console.log(`${msg}`); };
assert(st !== null, '提取器返回非空');
assert(!!st.title && st.title.length >= 5, `标题: ${st.title}`);
assert(st.galleryImages.length >= 5, `主图 ${st.galleryImages.length}`);
assert(st.videos.length >= 1 && /\.mp4/.test(st.videos[0].url), `视频: ${st.videos[0]?.url?.slice(0, 60) ?? '无'}`);
assert(st.skus.length >= 3, `SKU ${st.skus.length} 个(含规格名/图)`);
assert(st.skus.every(s => /:/.test(s.name)), `SKU : ${st.skus.slice(0, 3).map(s => s.name).join(' | ')}`);
assert(!!st.price && /\d/.test(st.price), `价格区间: ${st.price}`);
assert(!!st.sales && /\d/.test(st.sales), `销量: ${st.sales}`);
assert(!!st.shop && st.shop.length >= 2, `店铺: ${st.shop}`);
const dimPair = st.params.find(p => p.key === '产品尺寸');
assert(!!dimPair && /\d+×\d+×\d+/.test(dimPair.value), `产品尺寸: ${dimPair?.value}`);
assert(st.params.some(p => p.key === '重量'), `重量: ${st.params.find(p => p.key === '重量')?.value}`);
assert(!!st.detailUrl?.startsWith('https://'), `detailUrl: ${st.detailUrl?.slice(0, 70)}`);
const priced = st.skus.filter(s => s.price);
assert(priced.length >= 1, `SKU 价格明细 ${priced.length} 条(如 ${priced[0]?.name} ${priced[0]?.price}`);
console.log('\n全部断言通过 ✅');
+302
View File
@@ -0,0 +1,302 @@
/**
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
* 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。
*/
import type { ImageMaterial } from '../collector/scan';
/** 服务端支持的套图类型(与 server/services/prompts/common.py 保持一致) */
export const SUITE_TYPE_OPTIONS = [
{ value: 'white_bg', label: '白底主图' },
{ value: 'key_features', label: '核心卖点图' },
{ value: 'selling_pt', label: '卖点图' },
{ value: 'material', label: '材质图' },
{ value: 'lifestyle', label: '场景展示图' },
{ value: 'multi_scene', label: '多场景拼图' },
{ value: 'ecommerce_detail', label: '电商详情图' },
{ value: 'size_chart', label: '尺寸标注图' },
{ value: 'sku_collection', label: 'SKU合集图' },
{ value: 'custom', label: '创意图' },
] as const;
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
export interface PlanItem {
kind: string;
title: string;
detail: string;
prompt_hint: string;
count: number;
variant_name?: string | null;
}
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map(t => ({
kind: t.value, title: t.label, detail: '', prompt_hint: '', count: 1, variant_name: null,
}));
/** 视觉风格(名称 + 默认提示词,提示词可在插件里改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [
{
value: 1,
label: '北欧极简',
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
},
{
value: 2,
label: '清新明亮',
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
},
{
value: 3,
label: '高级感深色',
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
},
{
value: 4,
label: '暖调生活',
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
},
{
value: 5,
label: '纯净棚拍',
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
},
] as const;
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
export const PLATFORM_OPTIONS = [
{ value: 'ozon', label: 'Ozon' },
{ value: 'wb', label: 'Wildberries' },
{ value: 'cn', label: '中文' },
] as const;
export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value'];
/** 生图模型(下拉可选 + 中文特点说明;服务端按模型名路由 provider) */
export const IMAGE_MODEL_OPTIONS = [
{
value: 'qwen-image-3.0-pro',
label: 'qwen-image-3.0-pro',
desc: '同步生成,响应快、图文理解强,适合快速批量出图',
},
{
value: 'wan2.7-image-pro',
label: 'wan2.7-image-pro',
desc: '异步精修,质感与细节更强,适合高质量电商大片',
},
{
value: 'wan2.6-image',
label: 'wan2.6-image',
desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好',
},
{
value: 'wan2.6-t2i',
label: 'wan2.6-t2i',
desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快',
},
{
value: 'gpt-image-2',
label: 'gpt-image-2',
desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟',
},
{
value: 'gpt-image-2-vip',
label: 'gpt-image-2-vip',
desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图',
},
{
value: 'nano-banana',
label: 'nano-banana',
desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然',
},
{
value: 'nano-banana-2',
label: 'nano-banana-2',
desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选',
},
{
value: 'nano-banana-2-lite',
label: 'nano-banana-2-lite',
desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错',
},
{
value: 'nano-banana-pro',
label: 'nano-banana-pro',
desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片',
},
] as const;
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
wb: { lang: '俄文', ratio: '3:4', label: 'Wildberries' },
cn: { lang: '中文', ratio: '1:1', label: '中文' },
};
export interface SuiteImageInfo {
type_id: string;
name: string;
url: string;
status: string;
error?: string | null;
}
export interface SuiteInfo {
id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
style_set: number;
platform: string;
lang: string;
ratio: string;
provider: string;
total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total
images: SuiteImageInfo[];
error?: string | null;
}
function authHeaders(token: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** 生成图水印选项(服务端在 AI 出图后合成;shape 对齐 server WatermarkOptions */
export interface WatermarkPayload {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number;
}
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
export interface GeneratePayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
style_set: number;
style_prompt?: string;
requirements?: string | null;
plan: PlanItem[];
platform: string;
model?: string | null;
watermark?: WatermarkPayload;
}
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
export function buildGeneratePayload(
images: ImageMaterial[],
selectedKeys: Set<string>,
texts: GeneratePayload['texts'],
config: { style_set: number; style_prompt?: string; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null; watermark?: WatermarkPayload },
): GeneratePayload {
const selected = images
.filter((img) => selectedKeys.has(img.key))
.map((img) => ({ url: img.url, group_key: img.groupKey, variant_name: img.variantName ?? null }));
return { texts, images: selected, ...config };
}
/** 出图方案规划请求体 */
export interface PlanPayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
sku_variants: string[];
image_stats: Record<string, number>;
platform: string;
requirements?: string | null;
}
/** AI 智能规划:DeepSeek 根据商品信息生成出图方案 */
export async function planSuite(
baseUrl: string,
token: string,
payload: PlanPayload,
): Promise<{ summary: string; items: PlanItem[] }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `规划失败 HTTP ${res.status}`);
return data;
}
/** 无状态一键生成:后端直接用请求数据生图,不落商品库 */
export async function generateSuite(
baseUrl: string,
token: string,
payload: GeneratePayload,
): Promise<{ suite_id: string }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `提交失败 HTTP ${res.status}`);
return data;
}
/** 查询套图任务状态(轮询用) */
export async function getSuite(baseUrl: string, token: string, suiteId: string): Promise<SuiteInfo> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}`, {
headers: authHeaders(token),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `查询失败 HTTP ${res.status}`);
return data;
}
export function suiteZipUrl(baseUrl: string, suiteId: string): string {
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`;
}
/** 下载生成结果 ZIPGET → blob(由调用方经 chrome.downloads 落盘,文件名用标题) */
export async function downloadSuiteZip(
baseUrl: string,
token: string,
suiteId: string,
): Promise<Blob> {
const res = await fetch(suiteZipUrl(baseUrl, suiteId), { headers: authHeaders(token) });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.detail || `下载失败 HTTP ${res.status}`);
}
return res.blob();
}
/** 手动上传本地图片到服务端,返回可访问 URL(补充参考图用) */
export async function uploadImage(
baseUrl: string,
token: string,
file: File,
): Promise<{ url: string; key: string }> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/upload-image`, {
method: 'POST',
headers: authHeaders(token), // 不显式设 Content-Type,交给浏览器生成 boundary
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
return data;
}
/** 导出采集图片请求体:后端打包成 ZIP(内部按分组名建文件夹,文件名沿用采集 key) */
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
}
/** 导出采集图片:POST /api/export-images → ZIP blob(由调用方经 chrome.downloads 落盘) */
export async function exportImages(
baseUrl: string,
token: string,
payload: ExportImagesPayload,
): Promise<Blob> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/export-images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.detail || `导出失败 HTTP ${res.status}`);
}
return res.blob();
}
+46
View File
@@ -0,0 +1,46 @@
/**
* 商品上报客户端:把采集结果 POST 到 ozon-seller-kit 后台 /api/materials
* 返回 product_id 后由调用方打开商品试算页。契约对齐主仓 server/schemas/collection.py。
*/
import type { TextMaterial } from '../collector/merge';
export interface ReportProductPayload {
source: {
platform: string;
itemId: string | null;
url: string;
collectedAt?: number;
};
texts: TextMaterial[];
images: Array<{
groupKey: string;
groupName: string;
variantName: string | null;
url: string;
index: number;
type: string;
}>;
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com */
refererOrigin?: string;
}
export interface ReportProductResult {
product_id: string;
stage?: string;
assets_queued?: number;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS */
export async function reportProduct(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
const res = await fetch(`${reportBaseUrl.replace(/\/$/, '')}/api/materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
return data as ReportProductResult;
}
@@ -0,0 +1,43 @@
/**
* isolated world 侧的桥客户端:请求 MAIN world 读取页面全局变量。
* 自带重试(桥脚本可能比内容脚本晚注入),超时返回空对象——调用方降级 DOM。
*/
let seq = 0;
export function readWindowKeys(keys: string[], timeoutMs = 1200): Promise<Record<string, any>> {
const requestId = `sc-${Date.now()}-${seq++}`;
return new Promise((resolve) => {
let done = false;
const started = Date.now();
const cleanup = () => {
window.removeEventListener('message', onMsg);
clearTimeout(retryTimer);
clearTimeout(giveUpTimer);
};
const onMsg = (ev: MessageEvent) => {
if (ev.source !== window) return;
const d = ev.data as { type?: string; requestId?: string; payload?: Record<string, any> } | null;
if (d?.type === 'sc-bridge-res' && d.requestId === requestId) {
done = true;
cleanup();
resolve(d.payload ?? {});
}
};
window.addEventListener('message', onMsg);
const send = () => window.postMessage({ type: 'sc-bridge-req', requestId, keys }, '*');
send();
const retryTimer = setInterval(() => {
if (done) return;
if (Date.now() - started > timeoutMs) return;
send();
}, 250);
const giveUpTimer = setTimeout(() => {
if (done) return;
cleanup();
resolve({});
}, timeoutMs);
});
}
@@ -0,0 +1,168 @@
/**
* 1688 SSR 状态提取器(主路径)——读取 MAIN world 桥回传的 window.context。
*
* 页面第 4 个内联 script 把完整商品数据挂在 window.context
* window.context.result.data.<模块名>.fields
* 结构与 Ozon 的 data-state 同构(34 个模块)。本文件只做纯数据提取,
* 不碰 DOM / URL,方便用 reference/1688.html 快照离线验证(scripts/verify-1688.mjs)。
*/
export interface Sku1688 {
name: string; // "规格型号:黑盒【27件套】"
image?: string;
price?: string; // 该 SKU 价格
canBookCount?: number; // 该 SKU 库存
length?: number;
width?: number;
height?: number;
weight?: number;
}
export interface State1688 {
title?: string;
price?: string; // "19.90-24.60"
sales?: string; // 销量
shop?: string; // 公司/店铺名
unit?: string; // 单位(套/件)
offerId?: string;
categoryIds?: string[];
galleryImages: string[]; // 原图
videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[];
params: Array<{ key: string; value: string }>;
}
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
export function deepFind(root: unknown, key: string, maxNodes = 300_000): any {
if (root == null || typeof root !== 'object') return undefined;
const queue: unknown[] = [root];
const seen = new Set<object>();
let visited = 0;
while (queue.length) {
const cur = queue.shift();
if (cur == null || typeof cur !== 'object') continue;
if (++visited > maxNodes) return undefined;
if (seen.has(cur as object)) continue;
seen.add(cur as object);
for (const [k, v] of Object.entries(cur as Record<string, unknown>)) {
if (k === key) return v;
if (v && typeof v === 'object') queue.push(v);
}
}
return undefined;
}
const num = (v: unknown): number | undefined => {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
export function extract1688State(context: unknown): State1688 | null {
if (!context || typeof context !== 'object') return null;
// ── gallery:主图 + 视频 ──
const gallery = deepFind(context, 'gallery');
const galleryFields = gallery?.fields ?? gallery ?? {};
const mainImages: string[] = [];
const pushImg = (u: unknown) => {
if (typeof u === 'string' && /^https?:\/\//.test(u) && !mainImages.includes(u)) mainImages.push(u);
};
(galleryFields.mainImage ?? []).forEach(pushImg);
(galleryFields.offerImgList ?? []).forEach((it: any) => typeof it === 'string' ? pushImg(it) : pushImg(it?.imgUrl ?? it?.url ?? it?.image));
const videos: Array<{ url: string; cover?: string }> = [];
const videoObj = galleryFields.video;
if (videoObj?.videoUrl) videos.push({ url: videoObj.videoUrl, cover: videoObj.coverUrl });
(galleryFields.videos ?? []).forEach((v: any) => v?.videoUrl && videos.push({ url: v.videoUrl, cover: v.coverUrl }));
// ── tempModel(在 Root 模块里):标题/销量/公司/类目 ──
const temp = deepFind(context, 'tempModel') ?? {};
const title = typeof temp.offerTitle === 'string' ? temp.offerTitle : undefined;
// ── SKUskuModel.skuProps 全维度展开 ──
const skus: Sku1688[] = [];
const skuModel = deepFind(context, 'skuModel');
for (const prop of skuModel?.skuProps ?? []) {
for (const v of prop?.value ?? []) {
if (!v?.name) continue;
skus.push({
name: `${prop.prop ?? '规格'}:${v.name}`,
image: typeof v.imageUrl === 'string' ? v.imageUrl : undefined,
});
}
}
// ── 价格:区间 + 每 SKU 明细 ──
const tradeModel = deepFind(context, 'tradeModel') ?? {};
let price: string | undefined;
if (typeof tradeModel.minPrice === 'string' && typeof tradeModel.maxPrice === 'string') {
price = tradeModel.minPrice === tradeModel.maxPrice
? `¥${tradeModel.minPrice}`
: `¥${tradeModel.minPrice}-${tradeModel.maxPrice}`;
}
const skuMap = deepFind(context, 'skuMapOriginal') ?? [];
const byName = new Map(skus.map(s => [s.name.split(':').pop() ?? s.name, s]));
for (const row of skuMap) {
const s = byName.get(row?.specAttrs);
if (s) {
if (typeof row.price === 'string') s.price = `¥${row.price}`;
s.canBookCount = num(row.canBookCount);
}
}
// ── 件重尺:每个 SKU 的长宽高/体积/重量 ──
const packRows: any[] = deepFind(context, 'pieceWeightScaleInfo') ?? [];
const params: Array<{ key: string; value: string }> = [];
if (packRows.length) {
for (const r of packRows) {
const s = byName.get(r?.sku1);
if (s) {
s.length = num(r.length); s.width = num(r.width);
s.height = num(r.height); s.weight = num(r.weight);
}
}
const first = packRows[0];
if (num(first.length) && num(first.width) && num(first.height)) {
params.push({ key: '产品尺寸', value: `${first.length}×${first.width}×${first.height}cm` });
}
if (num(first.weight)) {
params.push({ key: '重量', value: `${first.weight}g` });
}
}
// ── 参数表:productAttributes(模块可能服务端报错为空,DOM 兜底)──
const attrs = deepFind(context, 'productAttributes');
const attrFields = attrs?.fields ?? {};
for (const row of attrFields.attributes ?? attrFields.props ?? []) {
const k = typeof row?.name === 'string' ? row.name : row?.propertyName;
const v = typeof row?.value === 'string' ? row.value : row?.valueName;
if (k && v) params.push({ key: String(k), value: String(v) });
}
// ── SKU 价格明细(少量时并入参数,供规划/尺寸图参考)──
const priced = skus.filter(s => s.price);
if (priced.length > 0 && priced.length <= 6) {
params.push({ key: 'SKU价格', value: priced.map(s => `${s.name.split(':').pop()} ${s.price}`).join('') });
}
const categoryIds = [
temp.postCategoryId ? String(temp.postCategoryId) : '',
temp.topCategoryId ? String(temp.topCategoryId) : '',
].filter(Boolean);
if (!title && mainImages.length === 0 && skus.length === 0) return null;
return {
title,
price,
sales: temp.saledCount != null ? `${temp.saledCount}` : undefined,
shop: typeof temp.companyName === 'string' ? temp.companyName : undefined,
unit: typeof temp.offerUnit === 'string' ? temp.offerUnit : undefined,
offerId: temp.offerId != null ? String(temp.offerId) : undefined,
categoryIds,
galleryImages: mainImages,
videos,
skus,
params,
};
}
+88
View File
@@ -0,0 +1,88 @@
/**
* DOM 工具 - 等待元素、Shadow DOM 穿透
* 从 extension-v1 移植
*/
/** 等待任一选择器出现(MutationObserver + 超时) */
export function waitForAny(
selectors: string[],
timeoutMs = 10_000
): Promise<Element | null> {
const hit = () => selectors.map((s) => document.querySelector(s)).find(Boolean) ?? null;
const found = hit();
if (found) return Promise.resolve(found);
return new Promise((resolve) => {
const timer = setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeoutMs);
const observer = new MutationObserver(() => {
const el = hit();
if (el) {
clearTimeout(timer);
observer.disconnect();
resolve(el);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
});
}
/** 穿透 Shadow DOM 查询元素(Ozon 部分组件用了 Web Components */
export function queryAllDeep(selectors: string[]): Element[] {
const out: Element[] = [];
for (const sel of selectors) {
let nodes: NodeListOf<Element>;
try {
nodes = document.querySelectorAll(sel);
} catch {
continue; // 选择器写错不能拖垮整个扫描
}
nodes.forEach((el) => {
if (el.shadowRoot) {
out.push(...Array.from(el.shadowRoot.querySelectorAll('img, video, source')));
} else {
out.push(el);
}
});
}
return out;
}
/**
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
* 等待页面高度增长,页面不再变高或达到步数上限即停——防止底部「为你推荐」无限加载把采集卡死。
* 滚完恢复原位。
*/
export async function autoScrollToBottom(
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
): Promise<void> {
const { stepPx = 500, stepMs = 400, maxSteps = 80 } = opts;
const startY = window.scrollY;
let lastHeight = document.body.scrollHeight;
let stagnant = 0; // 连续不增长的步数
// 每步在 0.7~1.3 倍步长、0.7~1.5 倍间隔内随机抖动,模拟人工节奏
const rand = (min: number, max: number) => min + Math.random() * (max - min);
for (let i = 0; i < maxSteps; i++) {
window.scrollBy({ top: Math.round(stepPx * rand(0.7, 1.3)), behavior: 'auto' });
await new Promise(r => setTimeout(r, Math.round(stepMs * rand(0.7, 1.5))));
const atBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 4;
const h = document.body.scrollHeight;
if (h > lastHeight + 50) {
lastHeight = h;
stagnant = 0; // 页面还在长(懒加载进来新内容),继续
} else if (atBottom) {
stagnant++;
if (stagnant >= 2) break; // 到底且连续两步没有新内容,收工
}
}
// 多数详情图是进入视口才加载,到底后再等一拍让 <img> 完成 src 替换
await new Promise(r => setTimeout(r, stepMs));
window.scrollTo({ top: startY, behavior: 'auto' });
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 图片提取 - 主图、SKU、详情图、视频
* 从 extension-v1 移植,新增:
* - srcset 处理(Ozon 画廊是 <img srcset> / <picture><source>
* - toOriginalUrl 传平台规则(Ozon /wc\d+/
*/
import {
toAbsoluteUrl,
toOriginalUrl,
urlInBrackets,
looksLikeImageUrl,
dedupeKey,
pickBestFromSrcset,
} from './url';
import { queryAllDeep } from './dom';
import type { ImageGroupKey, SiteProfile, SrcProp } from '../profiles/types';
export interface ImageMaterial {
key: string; // 'main-001'
groupKey: ImageGroupKey; // 'main'
groupName: string; // '主图'
variantName?: string; // SKU 规格名(仅 sku 组)
url: string; // 已还原为原图
thumbUrl: string; // 页面上的原始小图地址
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
/** 从元素上读出图片地址与名称,按 srcProps 顺序降级 */
function readImageSource(
el: Element,
srcProps: SrcProp[],
nameSelectors?: string[]
): { url: string; name: string; imgEl: HTMLImageElement | null } {
let url = '';
let name = '';
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
for (const prop of srcProps) {
if (url) break;
if (prop === 'backgroundImage') {
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
url = img.currentSrc || img.src || '';
name = img.alt || '';
} else {
const bg = getComputedStyle(el).backgroundImage || '';
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
if (looksLikeImageUrl(cand)) url = cand;
}
continue;
}
if (prop === 'srcset') {
// <img srcset> 或 <source srcset>
const raw = el.getAttribute('srcset') || (el as any).srcset || '';
if (raw) url = pickBestFromSrcset(raw);
continue;
}
const raw = (el as any)[prop] || el.getAttribute(prop);
if (raw) {
// srcset 场景下 currentSrc 才是实际加载的那张
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
}
}
// 选择器命中的是容器、图在子节点上
if (!url && el.tagName !== 'IMG') {
const inner = el.querySelector('img, source');
if (inner) {
const srcset = inner.getAttribute('srcset');
url = srcset
? pickBestFromSrcset(srcset)
: inner.getAttribute('data-src') || (inner as HTMLImageElement).currentSrc || (inner as HTMLImageElement).src || '';
if (inner instanceof HTMLImageElement) imgEl = inner;
if (!name && inner instanceof HTMLImageElement) name = inner.alt || '';
}
}
// 名称统一取(SKU 规格名)
if (!name && nameSelectors?.length) {
for (const sel of nameSelectors) {
const t = el.querySelector(sel)?.textContent?.trim();
if (t) {
name = t;
break;
}
}
}
return { url: url ? toAbsoluteUrl(url) : '', name, imgEl };
}
export function collectImages(profile: SiteProfile): ImageMaterial[] {
const result: ImageMaterial[] = [];
for (const group of profile.imageGroups) {
const srcProps = group.srcProps ?? profile.defaultSrcProps;
// 去重按组独立:一张图同时是主图和 SKU 图是正常的
const seen = new Set<string>();
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
for (const el of queryAllDeep(group.selectors)) {
if (activeSet.has(el)) continue;
if (group.excludeWithin?.some((sel) => el.closest(sel))) continue;
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
if (!rawUrl) continue;
if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8|webm)(\?|$)/i.test(rawUrl) && !/^blob:/i.test(rawUrl)) {
continue;
}
const url = group.type === 'img' ? toOriginalUrl(rawUrl, profile.originalUrlRules) : rawUrl;
// 尺寸过滤
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
const measured = imgEl ?? (el as HTMLElement);
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
}
const k = group.key === 'sku' ? `${dedupeKey(url, profile.originalUrlRules)}::${name}` : dedupeKey(url, profile.originalUrlRules);
if (seen.has(k)) continue;
seen.add(k);
result.push({
key: `${group.key}-${String(result.filter((r) => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
groupKey: group.key,
groupName: group.name,
variantName: group.key === 'sku' ? name || undefined : undefined,
url,
thumbUrl: rawUrl,
index: result.length,
type: group.type,
});
}
}
return result;
}
@@ -0,0 +1,107 @@
/**
* JSON-LD 提取器(schema.org/Product
*
* Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json
* 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。
*
* 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。
*/
export interface JsonLdProduct {
title?: string;
description?: string;
brand?: string;
sku?: string;
price?: string;
currency?: string;
images: string[];
rating?: string;
reviewCount?: string;
}
function asString(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
return undefined;
}
function findProduct(node: unknown): any | null {
if (Array.isArray(node)) {
for (const item of node) {
const r = findProduct(item);
if (r) return r;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const obj = node as Record<string, unknown>;
const type = obj['@type'];
const types = Array.isArray(type) ? type : [type];
if (types.some((t) => t === 'Product')) return obj;
// @graph 包裹
if (Array.isArray(obj['@graph'])) {
for (const g of obj['@graph']) {
const r = findProduct(g);
if (r) return r;
}
}
return null;
}
function collectImages(node: unknown, out: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectImages(n, out));
return;
}
if (typeof node === 'object') {
for (const v of Object.values(node as Record<string, unknown>)) {
collectImages(v, out);
}
}
}
export function extractJsonLd(): JsonLdProduct | null {
try {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of Array.from(scripts)) {
const text = script.textContent?.trim();
if (!text) continue;
let data: unknown;
try {
data = JSON.parse(text);
} catch {
continue;
}
const product = findProduct(data);
if (!product) continue;
const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers;
const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined);
const images: string[] = [];
if (product.image) collectImages(product.image, images);
return {
title: asString(product.name),
description: asString(product.description),
brand: asString(brandName),
sku: asString(product.sku),
price: asString(offers?.price),
currency: asString(offers?.priceCurrency),
images,
rating: asString(product.aggregateRating?.ratingValue),
reviewCount: asString(product.aggregateRating?.reviewCount),
};
}
} catch (err) {
console.warn('[JSON-LD] 提取失败:', err);
}
return null;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 通用合并器:各平台采集路径共用的结果装配逻辑。
* 从 scan.ts 抽出(平台拆分),平台文件只负责各路径的数据获取。
*/
import type { ImageMaterial } from './image';
import type { TextMaterial } from './text';
import type { BreadcrumbItem } from './ozon-state';
import type { SiteProfile } from '../profiles/types';
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
breadcrumbs: BreadcrumbItem[];
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0
source: 'state' | 'ssr' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
}
export type { ImageMaterial, TextMaterial };
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图片' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
/** 按组分组合并:靠前来源优先,靠后来源填缺,按 dedupeKey 去重后重排 index */
export function mergeImages(
primary: ImageMaterial[],
fallback: ImageMaterial[],
profile: SiteProfile
): ImageMaterial[] {
const byGroup = new Map<string, ImageMaterial[]>();
const seen = new Set<string>();
let counter = 0;
const push = (m: ImageMaterial) => {
const k = m.groupKey === 'sku'
? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}`
: dedupeKey(m.url, profile.originalUrlRules);
if (seen.has(k)) return;
seen.add(k);
const arr = byGroup.get(m.groupKey) ?? [];
arr.push({ ...m, index: counter++ });
byGroup.set(m.groupKey, arr);
};
for (const m of primary) push(m);
for (const m of fallback) push(m);
const out: ImageMaterial[] = [];
for (const g of GROUP_ORDER) {
const arr = byGroup.get(g.key);
if (!arr) continue;
arr.forEach((m, i) => {
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
m.groupName = g.name;
});
out.push(...arr);
}
return out;
}
import { dedupeKey } from './url';
/** 汇总统计与警告,产出最终 ScanResult */
export function finalize(
profile: SiteProfile,
itemId: string | null,
texts: TextMaterial[],
images: ImageMaterial[],
breadcrumbs: BreadcrumbItem[],
source: ScanResult['source']
): ScanResult {
const stats: Record<string, number> = {};
for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
return {
platform: profile.id,
itemId,
url: location.href,
texts,
images,
breadcrumbs,
scannedAt: Date.now(),
stats,
warnings,
source,
};
}
@@ -0,0 +1,283 @@
/**
* Ozon 内部页 JSON API 提取器(补充路径)
*
* 参考实现(毛子ERP)的采集核心是直接请求 Ozon 自己的页数据接口:
*
* GET {origin}/api/entrypoint-api.bx/page/json/v2?url=/product/{id}/
* → { widgetStates: { "webCharacteristics-…": "...", "webGallery-…": "...", ... } }
*
* ★ 关键点(毛子ERP 的做法,也是本文件修复点):
* - 默认页 `/product/{id}/` 里带 **webCharacteristics(全量「特征」)**
* SSR 里的 webShortCharacteristics 只给前 5 项(limit:5)。
* - 描述页 `/product/{id}/?layout_container=pdpPage2column&layout_page_index=2`
* 里带 webDescription(富文本描述)。
* 所以要两个 URL 都请求、合并,才能拿到完整参数表 + 描述。
*
* ★ 图片只从画廊类 widget 收(白名单),绝不递归全部 widgetStates
* 避免「为您推荐 / 一起购买」等 carousel 图混入。
*/
export interface OzonPageData {
title?: string;
price?: string;
oldPrice?: string;
description?: string;
/** 主图画廊(仅来自画廊 widget) */
images: string[];
videos: string[];
/** 参数表(kv */
characteristics: Array<{ key: string; value: string }>;
}
const IMG_EXT = /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i;
const VID_EXT = /\.(mp4|m3u8|webm|mov)(\?|$)/i;
function parseWidgetState(v: unknown): unknown {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function parseWidgetStates(widgetStates: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!widgetStates || typeof widgetStates !== 'object') return out;
for (const [k, v] of Object.entries(widgetStates as Record<string, unknown>)) {
out[k] = parseWidgetState(v);
}
return out;
}
function pushUnique(arr: string[], v: string): void {
if (v && !arr.includes(v)) arr.push(v);
}
/** 递归收集画廊 widget 内的图片/视频 URL(只在这个 widget 内走) */
function collectMedia(node: unknown, images: string[], videos: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (IMG_EXT.test(node)) pushUnique(images, node);
else if (VID_EXT.test(node)) pushUnique(videos, node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectMedia(n, images, videos));
return;
}
if (typeof node !== 'object') return;
for (const v of Object.values(node as Record<string, unknown>)) {
collectMedia(v, images, videos);
}
}
/** 从 characteristic 类 widget 里收参数表 */
function collectCharacteristics(node: unknown, out: Array<{ key: string; value: string }>): void {
if (!node || typeof node !== 'object') return;
const walk = (n: unknown): void => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
const obj = n as Record<string, unknown>;
for (const [k, v] of Object.entries(obj)) {
if (/characteristic|aspect/i.test(k) && Array.isArray(v)) {
for (const row of v) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
// { title: {textRs:[{content}]}, values:[{text}] }Ozon 实测结构)
const key = readText(r.title);
if (key && Array.isArray(r.values)) {
const vals = r.values
.map((x) => (x && typeof x === 'object' ? readText((x as Record<string, unknown>).text) : ''))
.filter(Boolean);
if (vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// { key/value } / { name/value } / { title/text }
const k2 = (r.key ?? r.name ?? r.title) as string | undefined;
const v2 = (r.value ?? r.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
} else if (/characteristic|aspect/i.test(k) && typeof v === 'object') {
walk(v);
}
}
};
walk(node);
}
function readText(node: unknown): string {
if (!node) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
// { textRs: [{ type, content }] } / { content } / { text }
const obj = node as Record<string, unknown>;
if (Array.isArray(obj.textRs)) {
return obj.textRs
.map((t) => (t && typeof t === 'object' ? (t as Record<string, unknown>).content ?? '' : ''))
.join('')
.trim();
}
if (typeof obj.content === 'string') return obj.content.trim();
if (typeof obj.text === 'string') return obj.text.trim();
return '';
}
/** 从描述类 widget 里收富文本描述 */
function collectDescription(node: unknown, out: { description?: string }): void {
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
if (typeof obj.richAnnotationJson === 'string') {
try {
const rich = JSON.parse(obj.richAnnotationJson);
out.description = richToString(rich);
} catch {
out.description = obj.richAnnotationJson;
}
return;
}
if (typeof obj.description === 'string') {
out.description = obj.description;
return;
}
}
/** richAnnotationJson(富文本块数组)→ 纯文本 */
function richToString(rich: unknown): string {
if (!rich) return '';
if (typeof rich === 'string') return rich;
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'text' && typeof v === 'string') texts.push(v);
else if (k !== 'type') walk(v);
}
}
};
walk(rich);
return texts.join('\n').trim();
}
/** 解析单个 widgetStates → 部分 OzonPageData */
function parsePage(widgets: Record<string, unknown>): OzonPageData {
const images: string[] = [];
const videos: string[] = [];
const characteristics: Array<{ key: string; value: string }> = [];
const desc: { description?: string } = {};
let title: string | undefined;
let price: string | undefined;
let oldPrice: string | undefined;
for (const [wkey, wval] of Object.entries(widgets)) {
const key = wkey.toLowerCase();
// 图片/视频:只收主画廊 widgetwebGallery),
// 不能按 "gallery" 子串匹配 —— webReviewGallery 是「买家照片和视频」,会混入
if (key.startsWith('webgallery')) {
collectMedia(wval, images, videos);
}
// 参数表(含全量 webCharacteristics
if (/(characteristic|aspect)/.test(key)) {
collectCharacteristics(wval, characteristics);
}
// 描述
if (/(description|richcontent)/.test(key)) {
collectDescription(wval, desc);
}
// 标题 / 价格(各自的 widget)
if (/heading|title/.test(key) && !title) {
const v = (wval as Record<string, unknown>)?.title ?? (wval as Record<string, unknown>)?.name;
if (typeof v === 'string' && v && !/^https?:/i.test(v)) title = v;
}
if (/webprice/.test(key) && !price) {
const p = (wval as Record<string, unknown>)?.price;
if (typeof p === 'string') price = p;
const op = (wval as Record<string, unknown>)?.originalPrice;
if (typeof op === 'string') oldPrice = op;
}
}
return {
title,
price,
oldPrice,
description: desc.description,
images,
videos,
characteristics: dedupePairs(characteristics),
};
}
async function fetchPage(url: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const json = (await res.json()) as { widgetStates?: unknown };
return parseWidgetStates(json.widgetStates);
} catch (err) {
console.warn('[Ozon API] 请求失败:', url, err);
return null;
}
}
export async function fetchOzonPageData(itemId: string): Promise<OzonPageData | null> {
// 默认页(标题/价格/画廊 + 全量特征 webCharacteristics+ 描述页(富文本描述)
const urls = [
`/product/${itemId}/`,
`/product/${itemId}/?layout_container=pdpPage2column&layout_page_index=2`,
];
const merged: OzonPageData = { images: [], videos: [], characteristics: [] };
let gotAny = false;
for (const target of urls) {
const widgets = await fetchPage(
`${location.origin}/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(target)}`,
);
if (!widgets) continue;
const p = parsePage(widgets);
gotAny = true;
merged.title = merged.title || p.title;
merged.price = merged.price || p.price;
merged.oldPrice = merged.oldPrice || p.oldPrice;
merged.description = merged.description || p.description;
for (const img of p.images) if (!merged.images.includes(img)) merged.images.push(img);
for (const v of p.videos) if (!merged.videos.includes(v)) merged.videos.push(v);
for (const c of p.characteristics) merged.characteristics.push(c);
}
merged.characteristics = dedupePairs(merged.characteristics);
return gotAny &&
(merged.images.length || merged.title || merged.price || merged.characteristics.length || merged.description)
? merged
: null;
}
function dedupePairs(pairs: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> {
const seen = new Set<string>();
const out: Array<{ key: string; value: string }> = [];
for (const p of pairs) {
const k = `${p.key}::${p.value}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(p);
}
return out;
}
@@ -0,0 +1,244 @@
/**
* Ozon SSR widget state 提取器(主路径)
*
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
* <div id="state-webGallery-3311626-default-1" data-state='{...}'>
* content script 直接读 data-state 即可,无需访问页面 JSmain world)。
*
* 结构已在真实页面实测(reference/ozon1.html、ozon2.html):
* - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}]
* - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥"
* - webProductHeading: title
* - webShortCharacteristics / webDetailedCharacteristics: characteristics[]
* - webAspects: aspects[].variants[].data.{searchableText, coverImage}SKU 变体)
* - webReviewProductScore: totalScore / reviewsCount
*
* ★ 白名单机制:只读上面这几个 widget 的 state。
* 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state
* webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。
*/
import { toAbsoluteUrl } from './url';
export interface OzonVariant {
name: string;
image?: string; // 可能为 undefined(纯文字规格,如尺码)
}
export interface BreadcrumbItem {
name: string; // 类目名称(如"扑满"、"儿童房"
href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041
searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析)
slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID
}
export interface OzonStateData {
title?: string;
price?: string;
originalPrice?: string;
rating?: string;
reviewCount?: string;
galleryImages: string[]; // 原图(无尺寸标记)
videos: string[];
videoCovers: string[];
skuVariants: OzonVariant[];
characteristics: Array<{ key: string; value: string }>;
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径
}
/** 允许读取的 widget 前缀白名单 */
const ALLOWED_WIDGETS = [
'webGallery-',
'webPrice-',
'webProductHeading-',
'webShortCharacteristics-',
'webDetailedCharacteristics-',
'webCharacteristics-',
'webAspects-',
'webReviewProductScore-',
'breadCrumbs-', // 面包屑类目路径
];
function pushUnique(arr: string[], v: string): void {
const abs = toAbsoluteUrl(v);
if (abs && !arr.includes(abs)) arr.push(abs);
}
function readTextRs(node: unknown): string {
// 提取 textRs / descriptionRs 里的展示文本。
// 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text
// 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。
if (node == null) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) {
texts.push('\n');
} else if (k === 'content' || k === 'text') {
walk(v);
} else if (v && typeof v === 'object') {
walk(v);
}
// 其它原始值(font/color/id/type='text' 等)直接跳过
}
}
};
walk(node);
return texts.join('').trim();
}
function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(chars)) return [];
const out: Array<{ key: string; value: string }> = [];
for (const c of chars) {
if (!c || typeof c !== 'object') continue;
const row = c as Record<string, unknown>;
// 结构 A{ title: { textRs: [...] }, values: [{ text: ... }] }(实测)
const key = readTextRs(row.title);
if (Array.isArray(row.values)) {
const vals = row.values
.map((v) => (v && typeof v === 'object' ? readTextRs((v as Record<string, unknown>).text) : ''))
.map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, "
.filter(Boolean);
if (key && vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// 结构 B{ key, value } / { name, value } / { title, text }
const k2 = (row.key ?? row.name ?? row.title) as string | undefined;
const v2 = (row.value ?? row.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
return out;
}
export function extractOzonState(): OzonStateData {
const data: OzonStateData = {
galleryImages: [],
videos: [],
videoCovers: [],
skuVariants: [],
characteristics: [],
breadcrumbs: [],
};
const seenChars = new Set<string>();
const els = document.querySelectorAll('div[id^="state-"]');
for (const el of Array.from(els)) {
const id = el.id.slice('state-'.length);
if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue;
const raw = el.getAttribute('data-state');
if (!raw) continue;
let state: unknown;
try {
state = JSON.parse(raw);
} catch {
continue;
}
if (!state || typeof state !== 'object') continue;
const s = state as Record<string, unknown>;
if (id.startsWith('webGallery-')) {
if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage);
if (Array.isArray(s.images)) {
for (const img of s.images) {
const src = img && typeof (img as Record<string, unknown>).src === 'string'
? (img as Record<string, unknown>).src as string
: undefined;
if (src) pushUnique(data.galleryImages, src);
}
}
if (Array.isArray(s.videos)) {
for (const v of s.videos) {
const rec = v as Record<string, unknown>;
if (typeof rec.url === 'string') pushUnique(data.videos, rec.url);
if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl);
}
}
} else if (id.startsWith('webPrice-')) {
if (typeof s.price === 'string') data.price = s.price;
if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice;
if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice;
} else if (id.startsWith('webProductHeading-')) {
if (typeof s.title === 'string') data.title = s.title;
} else if (
id.startsWith('webShortCharacteristics-') ||
id.startsWith('webDetailedCharacteristics-') ||
id.startsWith('webCharacteristics-')
) {
for (const c of parseCharacteristics(s.characteristics)) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
data.characteristics.push(c);
}
}
} else if (id.startsWith('webAspects-')) {
if (Array.isArray(s.aspects)) {
for (const aspect of s.aspects) {
const a = aspect as Record<string, unknown>;
if (!Array.isArray(a.variants)) continue;
for (const v of a.variants) {
const rec = v as Record<string, unknown>;
const d = rec.data as Record<string, unknown> | undefined;
const name = typeof d?.searchableText === 'string' ? d.searchableText
: typeof d?.title === 'string' ? d.title : '';
const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined;
if (name) data.skuVariants.push({ name, image });
}
}
}
} else if (id.startsWith('webReviewProductScore-')) {
if (typeof s.totalScore === 'number') data.rating = String(s.totalScore);
if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount);
} else if (id.startsWith('breadCrumbs-')) {
// breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] }
if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) {
for (const crumb of s.breadcrumbs) {
const c = crumb as Record<string, unknown>;
const name = typeof c.text === 'string' ? c.text.trim() : '';
const href = typeof c.link === 'string' ? c.link : '';
if (!name || !href) continue;
// 解析 ?category=7041highlight 样式链接)
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
// 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
}
// 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol
if (data.breadcrumbs.length === 0) {
const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]');
if (ol) {
for (const a of Array.from(ol.querySelectorAll('a[href]'))) {
const href = a.getAttribute('href') ?? '';
const name = a.textContent?.trim() ?? '';
if (!name) continue;
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
return data;
}
@@ -0,0 +1,95 @@
/**
* 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
* 详情图改为滚动加载后由 DOM 采集补齐。
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
export async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let st: State1688 | null = null;
// ① 桥读 window.context
const keys = await readWindowKeys(['context']);
st = extract1688State(keys['context']);
if (st) {
if (st.title) primaryTexts.push({ kind: 'title', content: st.title });
if (st.price) primaryTexts.push({ kind: 'price', content: st.price });
if (st.sales) primaryTexts.push({ kind: 'sales', content: st.sales });
if (st.shop) primaryTexts.push({ kind: 'shop', content: st.shop });
if (st.params.length) {
primaryTexts.push({
kind: 'params',
content: st.params.map(p => `${p.key}: ${p.value}`).join('\n'),
pairs: st.params,
});
}
let idx = 0;
st.galleryImages.forEach(u => {
primaryImages.push({
key: `main-${String(idx + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: toOriginalUrl(u),
thumbUrl: u,
index: idx++,
type: 'img',
});
});
st.skus.forEach(s => {
if (!s.image) return;
primaryImages.push({
key: `sku-${String(primaryImages.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: s.name || undefined,
url: toOriginalUrl(s.image),
thumbUrl: s.image,
index: idx++,
type: 'img',
});
});
st.videos.forEach(v => {
primaryImages.push({
key: `video-${String(primaryImages.filter(m => m.groupKey === 'video').length + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: v.url,
thumbUrl: v.cover ?? '',
index: idx++,
type: 'video',
});
});
source = 'state';
}
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
const domImages = collectImages(profile);
if (primaryImages.length > 0 && domImages.length > 0 && source === 'state') source = 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -0,0 +1,160 @@
/**
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
* ① SSR widget stateDOM data-state 属性,白名单)★主路径
* ② JSON-LDschema.org/Product
* ③ 站内页 JSON APIentrypoint-api.bx
* ④ DOM data-widget 选择器兜底 + 详情图补充
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl, toThumbUrl } from '../url';
import { extractJsonLd } from '../jsonld';
import { fetchOzonPageData, type OzonPageData } from '../ozon-api';
import { extractOzonState, type OzonStateData } from '../ozon-state';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
interface StructuredBundle {
title?: string;
price?: string;
brand?: string;
description?: string;
characteristics: Array<{ key: string; value: string }>;
galleryImages: string[];
videos: string[];
videoCovers: string[];
skuVariants: Array<{ name: string; image?: string }>;
}
function mergeStructured(
state: OzonStateData,
jsonld: ReturnType<typeof extractJsonLd>,
api: OzonPageData | null
): StructuredBundle {
const bundle: StructuredBundle = {
title: state.title || jsonld?.title || api?.title,
price: state.price || jsonld?.price || api?.price,
brand: jsonld?.brand,
description: api?.description || jsonld?.description,
characteristics: [...state.characteristics],
galleryImages: [...state.galleryImages],
videos: [...state.videos],
videoCovers: [...state.videoCovers],
skuVariants: [...state.skuVariants],
};
for (const u of api?.images ?? []) {
if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u);
}
for (const u of api?.videos ?? []) {
if (!bundle.videos.includes(u)) bundle.videos.push(u);
}
const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`));
for (const c of api?.characteristics ?? []) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
bundle.characteristics.push(c);
}
}
return bundle;
}
function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): {
texts: TextMaterial[];
images: ImageMaterial[];
} {
const texts: TextMaterial[] = [];
const images: ImageMaterial[] = [];
if (bundle.title) texts.push({ kind: 'title', content: bundle.title });
if (bundle.price) texts.push({ kind: 'price', content: bundle.price });
if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand });
if (bundle.characteristics.length) {
texts.push({
kind: 'params',
content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs: bundle.characteristics,
});
}
if (bundle.description) texts.push({ kind: 'desc', content: bundle.description });
let idx = 0;
bundle.galleryImages.forEach((u, i) => {
const orig = toOriginalUrl(u, profile.originalUrlRules);
images.push({
key: `main-${String(i + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: orig,
thumbUrl: toThumbUrl(orig),
index: idx++,
type: 'img',
});
});
bundle.skuVariants.forEach((s, i) => {
if (!s.image) return;
const orig = toOriginalUrl(s.image, profile.originalUrlRules);
images.push({
key: `sku-${String(i + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: s.name || undefined,
url: orig,
thumbUrl: toThumbUrl(orig),
index: idx++,
type: 'img',
});
});
bundle.videos.forEach((u, i) => {
images.push({
key: `video-${String(i + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: u,
thumbUrl: bundle.videoCovers[i] ?? '',
index: idx++,
type: 'video',
});
});
return { texts, images };
}
export async function scanOzon(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const state = extractOzonState();
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
const jsonld = extractJsonLd();
let api: OzonPageData | null = null;
if (itemId) {
try {
api = await fetchOzonPageData(itemId);
} catch (err) {
console.warn('[SuiteCollector] API 提取异常:', err);
}
}
const bundle = mergeStructured(state, jsonld, api);
const structured = buildFromBundle(profile, bundle);
if ((structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0) && source === 'dom') {
source = 'mixed';
}
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const domTexts = collectTexts(profile).materials;
const domImages = collectImages(profile);
const texts = mergeTexts(structured.texts, domTexts);
const images = mergeImages(structured.images, domImages, profile);
return finalize(profile, itemId, texts, images, state.breadcrumbs, source);
}
@@ -0,0 +1,49 @@
/**
* 淘宝/天猫 采集编排(平台文件):
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* isolated world 读不到 window 变量,v1 直读是无效的)
* ② DOM 兜底 + 补充
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge } from '../taobao-state';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
export async function scanTaobao(profile: SiteProfile, _itemId: string | null): Promise<ScanResult> {
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
// ① 桥读页面全局
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
const ssrData = taobaoStateFromBridge(keys);
if (ssrData && (ssrData.item.title || (ssrData.item.images ?? []).length > 0)) {
const built = buildFromSSR(ssrData, profile);
// ssr-builder 的本地类型 groupKey 是 string,这里对齐到 ImageGroupKey
primaryTexts = built.texts;
primaryImages = built.images as ImageMaterial[];
source = 'ssr';
}
// ② DOM 兜底 + 补充
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
const domImages = collectImages(profile);
if (primaryImages.length > 0 && domImages.length > 0) source = source === 'dom' ? source : 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, _itemId, texts, images, [], source);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -0,0 +1,63 @@
/**
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
*
* 平台编排逻辑(各路径与合并策略)见:
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM
* platforms/taobao.ts 淘宝/天猫(桥读全局 SSR / DOM)
* platforms/1688.ts 1688(桥读 window.context SSR / DOM
* 共用合并器见 merge.ts。
*/
import { matchProfile } from '../profiles';
import { readWindowKeys } from '../bridge/read-window';
import { scanOzon } from './platforms/ozon';
import { scanTaobao } from './platforms/taobao';
import { scan1688 } from './platforms/1688';
import type { ScanResult } from './merge';
export type { ScanResult };
export type { ImageMaterial, TextMaterial } from './merge';
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
if (!profile) {
console.warn('[SuiteCollector] 当前页面不支持采集:', location.href);
return null;
}
const itemId = profile.extractItemId(location.href);
console.log('[SuiteCollector] 开始采集:', profile.name, itemId, location.href);
let result: ScanResult | null = null;
try {
if (profile.id === 'ozon') result = await scanOzon(profile, itemId);
else if (profile.id === 'taobao') result = await scanTaobao(profile, itemId);
else result = await scan1688(profile, itemId);
} catch (err) {
console.error('[SuiteCollector] 采集异常:', err);
return null;
}
console.log('[SuiteCollector] 采集完成:', {
platform: result.platform,
texts: result.texts.map((t) => t.kind),
images: result.images.length,
stats: result.stats,
warnings: result.warnings,
source: result.source,
});
return result;
}
// 诊断工具:列出页面全局数据键(在商品页 console 跑 __SuiteCollector.probe()
export async function probeWindowKeys(): Promise<string[]> {
const res = await readWindowKeys(['*'], 1500);
return (res['__sc_window_keys__'] as string[]) ?? [];
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
probe: probeWindowKeys,
};
}
@@ -0,0 +1,157 @@
/**
* 从 SSR JSON 构建 ScanResult
*/
import { toOriginalUrl } from './url';
import type { SiteProfile } from '../profiles/types';
import type { SSRData } from './ssr';
// 直接定义类型避免循环依赖
interface TextMaterial {
kind: 'title' | 'price' | 'params' | 'desc' | 'selling_point' | 'brand' | 'sales' | 'shop';
content: string;
pairs?: Array<{ key: string; value: string }>;
}
interface ImageMaterial {
key: string;
groupKey: string;
groupName: string;
variantName?: string;
url: string;
thumbUrl: string;
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
scannedAt: number;
stats: Record<string, number>;
warnings: string[];
}
export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
const texts: TextMaterial[] = [];
const images: ImageMaterial[] = [];
// 1. 标题(必需)
texts.push({
kind: 'title',
content: data.item.title
});
// 2. 价格
if (data.price?.priceText) {
texts.push({
kind: 'price',
content: `${data.price.priceText}`
});
}
// 3. 参数表
const allParams = [
...(data.params?.basicParamList || []),
...(data.params?.enhanceParamList || [])
];
if (allParams.length > 0) {
const pairs = allParams
.filter(p => p.propertyName && p.valueName)
.map(p => ({ key: p.propertyName, value: p.valueName }));
if (pairs.length > 0) {
texts.push({
kind: 'params',
content: pairs.map(p => `${p.key}: ${p.value}`).join('\n'),
pairs
});
}
}
// 4. 主图(item.images
let idx = 0;
(data.item.images || []).forEach((url, i) => {
if (!url) return;
const origUrl = toOriginalUrl(url);
images.push({
key: `main-${String(i + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: origUrl,
thumbUrl: url,
index: idx++,
type: 'img'
});
});
// 5. SKU 图(skuBase.props 全维度展开:颜色分类、尺码等)
// 多维规格时名称带维度前缀("尺码:M"),单维保持原名("粉色")
const skuPropsList = data.skuBase?.props ?? [];
const multiDim = skuPropsList.length > 1;
for (const skuProp of skuPropsList) {
(skuProp.values ?? []).forEach(v => {
if (!v.image) return; // 有些 SKU 没配图(如纯文字规格)
const origUrl = toOriginalUrl(v.image);
const name = multiDim ? `${skuProp.name}:${v.name}` : v.name;
images.push({
key: `sku-${String(images.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: name || undefined,
url: origUrl,
thumbUrl: v.image,
index: idx++,
type: 'img'
});
});
}
// 6. 视频(item.videos
(data.item.videos || []).forEach((v, i) => {
if (!v.url) return;
images.push({
key: `video-${String(i + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: v.url,
thumbUrl: v.videoThumbnailURL || v.url,
index: idx++,
type: 'video'
});
});
// 统计各组数量
const stats: Record<string, number> = {};
for (const img of images) {
stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
}
// 生成警告
const warnings: string[] = [];
if (texts.length === 0) {
warnings.push('未提取到任何文本');
}
if (images.length === 0) {
warnings.push('未扫描到任何图片/视频');
}
// SSR 数据里没有详情图,需要 DOM 补充
if (stats.detail === undefined) {
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
}
return {
platform: profile.id,
itemId: data.item.itemId,
url: location.href,
texts,
images,
scannedAt: Date.now(),
stats,
warnings
};
}
+79
View File
@@ -0,0 +1,79 @@
/**
* SSR 数据提取器 - 淘宝/天猫页面内嵌 JSON
*
* 页面 HTML 里有完整商品数据挂在 window.__ICE_APP_CONTEXT__
* 包含标题、主图、SKU(图+名)、价格、参数,比 DOM 采集稳定 10 倍:
* - 不受懒加载影响
* - 不受改版影响(JSON 结构远比 CSS 类名稳定)
* - 一次拿全所有 SKU,无需滚动
*
* 当前只支持淘宝/天猫(__ICE_APP_CONTEXT__),
* 其他平台返回 null,触发 DOM 降级。
*/
export interface SSRData {
item: {
title: string;
itemId: string;
images: string[];
videos?: Array<{ url: string; videoThumbnailURL?: string }>;
};
skuBase?: {
props: Array<{
pid: string;
name: string; // "颜色分类" / "商品规格"
values: Array<{
vid: string;
name: string; // SKU 规格名
image?: string; // SKU 图片
}>;
}>;
};
params?: {
basicParamList?: Array<{ propertyName: string; valueName: string }>;
enhanceParamList?: Array<{ propertyName: string; valueName: string }>;
};
price?: {
priceText?: string;
priceMoney?: string;
};
}
/**
* 尝试从页面提取 SSR 数据(淘宝/天猫 __ICE_APP_CONTEXT__
*/
export function extractSSRData(): SSRData | null {
try {
const ctx = (window as any).__ICE_APP_CONTEXT__;
if (!ctx?.loaderData?.home?.data?.res) return null;
const res = ctx.loaderData.home.data.res;
// 基础结构验证
if (!res.item?.title || !res.item?.itemId) return null;
// 提取参数(两个来源都试)
const industryParams = res.plusViewVO?.industryParamVO;
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
(i: any) => i.type === 'BASE_PROPS'
);
return {
item: {
title: res.item.title,
itemId: res.item.itemId,
images: res.item.images || [],
videos: res.item.videos
},
skuBase: res.skuBase,
params: {
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
enhanceParamList: industryParams?.enhanceParamList || []
},
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice
};
} catch (err) {
console.warn('[SSR] 提取失败:', err);
return null;
}
}
@@ -0,0 +1,115 @@
/**
* 淘宝/天猫 SSR 状态提取 —— 通过 MAIN world 桥读取页面全局变量。
*
* 候选键(selectors-taobao.md §5.3 列出的待评估项):
* __ICE_APP_CONTEXT__ ICE 框架上下文(loaderData.home.data.resv1 已知结构)★主
* __general_skupanel_cache_data 疑似完整 SKU 面板缓存(结构未知,容错深搜)
* __ICE_DATA_LOADER__ ICE 框架数据层(容错深搜)
*
* 输出对齐 ssr.ts 的 SSRData,供 buildFromSSR 消费。
*/
import type { SSRData } from './ssr';
/** 从 __ICE_APP_CONTEXT__ 结构映射(原 v1 extractSSRData 的对象版) */
function fromIceContext(ctx: unknown): SSRData | null {
const res = (ctx as any)?.loaderData?.home?.data?.res;
if (!res?.item?.title || !res?.item?.itemId) return null;
const industryParams = res.plusViewVO?.industryParamVO;
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
(i: any) => i.type === 'BASE_PROPS'
);
return {
item: {
title: res.item.title,
itemId: res.item.itemId,
images: res.item.images || [],
videos: res.item.videos,
},
skuBase: res.skuBase,
params: {
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
enhanceParamList: industryParams?.enhanceParamList || [],
},
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice,
};
}
/** 容错:未知结构里深搜「SKU props 数组」(元素含 name + values/props 嵰 name/imageUrl */
function skuPropsFromUnknown(root: unknown): SSRData['skuBase'] | null {
const candidates: any[] = [];
const collect = (node: unknown, depth = 0) => {
if (!node || typeof node !== 'object' || depth > 6 || candidates.length) return;
if (Array.isArray(node)) {
if (
node.length >= 1 && node.every((x: any) => x && typeof x === 'object' &&
typeof (x.prop ?? x.name) === 'string' && Array.isArray(x.values ?? x.props))
) { candidates.push(node.map((x: any) => ({
pid: String(x.pid ?? ''),
name: x.prop ?? x.name,
values: (x.values ?? x.props).map((v: any) => ({
vid: String(v.vid ?? ''),
name: v.name ?? v.valueName ?? '',
image: v.image ?? v.imageUrl,
})),
}))); return;
}
node.forEach(n => collect(n, depth + 1));
return;
}
for (const v of Object.values(node as Record<string, unknown>)) collect(v, depth + 1);
};
collect(root);
return candidates.length ? { props: candidates[0] } : null;
}
/** 容错:深搜参数数组(元素含 propertyName/valueName */
function paramsFromUnknown(root: unknown): Array<{ propertyName: string; valueName: string }> {
const out: Array<{ propertyName: string; valueName: string }> = [];
const seen = new Set<string>();
const walk = (node: unknown, depth = 0) => {
if (!node || typeof node !== 'object' || depth > 6 || out.length > 60) return;
if (Array.isArray(node)) {
if (node.length >= 2 && node.every((x: any) => x && typeof x === 'object' &&
typeof x.propertyName === 'string' && typeof x.valueName === 'string')) {
for (const p of node) {
const k = `${p.propertyName}=${p.valueName}`;
if (!seen.has(k)) { seen.add(k); out.push({ propertyName: p.propertyName, valueName: p.valueName }); }
}
}
node.forEach(n => walk(n, depth + 1));
return;
}
for (const v of Object.values(node as Record<string, unknown>)) walk(v, depth + 1);
};
walk(root);
return out;
}
export function taobaoStateFromBridge(keys: Record<string, any>): SSRData | null {
// 主路径:ICE 上下文
const ice = fromIceContext(keys['__ICE_APP_CONTEXT__']);
if (ice) {
// 主路径缺 SKU 时用面板缓存补
if (!ice.skuBase?.props?.length) {
const fromCache = skuPropsFromUnknown(keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__']);
if (fromCache?.props?.length) ice.skuBase = fromCache;
}
return ice;
}
// 降级:只有面板缓存/数据层 —— 尽力拼一个最小 SSRData(标题给空,DOM 会补)
const cacheRoot = keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__'];
if (cacheRoot) {
const skuBase = skuPropsFromUnknown(cacheRoot);
const params = paramsFromUnknown(cacheRoot);
if (skuBase?.props?.length || params.length) {
return {
item: { title: '', itemId: '', images: [], videos: [] },
skuBase: skuBase ?? undefined,
params: { basicParamList: params, enhanceParamList: [] },
price: undefined,
};
}
}
return null;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
* 从 extension-v1 移植(DOM 兜底路径)
*/
import type { SiteProfile, TextRule } from '../profiles/types';
export interface TextMaterial {
kind: TextRule['kind'];
content: string;
pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果
}
function clean(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
function extractOne(rule: TextRule): TextMaterial | null {
for (const sel of rule.selectors) {
let nodes: NodeListOf<Element>;
try {
nodes = document.querySelectorAll(sel);
} catch {
continue;
}
if (!nodes.length) continue;
// cells 模式:兄弟键值对(antd Descriptions 的 th/td、dl 的 dt/dd
// selectors 命中的每个节点就是「键」,值取它的下一个兄弟元素
if (rule.extract === 'cells') {
const pairs: Array<{ key: string; value: string }> = [];
nodes.forEach((k) => {
const v = k.nextElementSibling;
if (!v) return;
const kc = clean(k.textContent ?? '');
const vc = clean(v.textContent ?? '');
if (kc && vc) pairs.push({ key: kc.replace(/[:]$/, ''), value: vc });
});
if (pairs.length) {
return {
kind: rule.kind,
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs,
};
}
continue;
}
// table 模式:参数表
if (rule.extract === 'table') {
const pairs: Array<{ key: string; value: string }> = [];
nodes.forEach((row) => {
const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? '');
const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? '');
if (k && v) pairs.push({ key: k.replace(/[:]$/, ''), value: v });
});
if (pairs.length) {
return {
kind: rule.kind,
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs,
};
}
continue;
}
// join 模式:标题被拆成多个 span
if (rule.extract === 'join') {
let text = '';
nodes.forEach((n) => {
text += n.textContent ?? '';
});
text = clean(text);
if (text) return { kind: rule.kind, content: text };
continue;
}
// first 模式:只取第一个
const first = clean(nodes[0].textContent ?? '');
if (first) return { kind: rule.kind, content: first };
}
return null;
}
export function collectTexts(profile: SiteProfile): {
materials: TextMaterial[];
missingRequired: string[];
} {
const materials: TextMaterial[] = [];
const missingRequired: string[] = [];
for (const rule of profile.textRules) {
const m = extractOne(rule);
if (m) materials.push(m);
else if (rule.required) missingRequired.push(rule.kind);
}
return { materials, missingRequired };
}
/** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。
* 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重),
* 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。
*/
export function mergeTexts(
primary: TextMaterial[],
fallback: TextMaterial[]
): TextMaterial[] {
const map = new Map<string, TextMaterial>();
for (const m of [...primary, ...fallback]) {
if (m.kind === 'params') {
const existing = map.get('params');
if (!existing) {
map.set('params', { ...m, pairs: [...(m.pairs ?? [])] });
} else {
const merged = [...(existing.pairs ?? [])];
const seen = new Set(merged.map((p) => p.key));
for (const p of m.pairs ?? []) {
if (!seen.has(p.key)) {
merged.push(p);
seen.add(p.key);
}
}
existing.pairs = merged;
existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n');
}
continue;
}
if (!map.has(m.kind)) map.set(m.kind, m);
}
return Array.from(map.values());
}
+132
View File
@@ -0,0 +1,132 @@
/**
* URL 工具链
* 从 extension-v1 移植,新增:
* - toOriginalUrl 支持平台自定义规则(Ozon 的 /wc\d+/ 路径段尺寸标记)
* - pickBestFromSrcset:从 srcset 里挑最大尺寸候选
*/
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
export interface UrlRule {
match: RegExp;
replace: string;
}
/**
* 缩略图 URL → 原图 URL
* 先走平台规则(Ozon 的 /wc\d+/ → /wc1200/),
* 再走阿里系通用规则:xxx.jpg_400x400.jpg → xxx.jpg
*/
export function toOriginalUrl(url: string, rules?: UrlRule[]): string {
let out = url;
for (const r of rules ?? []) {
// 带 g 标志的正则(query 清洗)要反复 replace,不带 g 的只替换一次
if (r.match.global) {
out = out.replace(r.match, r.replace);
} else if (r.match.test(out)) {
out = out.replace(r.match, r.replace);
}
}
const m = out.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i);
return m ? m[1] : out;
}
/** url("https://...") → https://... */
export function urlInBrackets(s: string): string {
if (!s?.trim()) return '';
return s.match(/\((.*?)\)/)?.[1]?.replace(/['"]/g, '') ?? '';
}
export function isDataUrl(u: string): boolean {
return /^data:image/.test(u);
}
/** 协议相对 // / 根相对 / / 相对路径 → 绝对 URL */
export function toAbsoluteUrl(u: string): string {
if (!u) return u;
if (isDataUrl(u) || u.startsWith('blob:')) return u;
const proto = u.startsWith('http:') ? 'http' : 'https';
if (/^\/\//.test(u)) return `${proto}:${u}`;
if (/^\//.test(u)) return `${location.origin}${u}`;
if (!/^(.*):/.test(u)) return `${location.origin}/${u}`;
return u;
}
/** 去重用的归一化 key:还原原图 + 剥 query/hash */
export function dedupeKey(url: string, rules?: UrlRule[]): string {
const base = toOriginalUrl(url, rules);
try {
const u = new URL(base);
u.search = '';
u.hash = '';
return u.toString();
} catch {
return base;
}
}
export function looksLikeImageUrl(u: string): boolean {
if (isDataUrl(u)) return true;
try {
return IMG_EXT.test(new URL(u).pathname);
} catch {
return IMG_EXT.test(u);
}
}
/**
* 从 srcset 里挑最大尺寸候选。
* 支持两种语法:
* "a.jpg 100w, b.jpg 200w, c.jpg 300w" → c.jpg
* "a.jpg 1x, b.jpg 2x" → 最后一个
* "a.jpg 400w, b.jpg 800w, c.jpg 1200w, d.jpg" → 最后一个(无描述符 = 兜底最大)
*/
export function pickBestFromSrcset(srcset: string): string {
if (!srcset) return '';
const parts = srcset.split(',').map((p) => p.trim()).filter(Boolean);
if (!parts.length) return '';
let best = '';
let bestSize = -1;
for (const part of parts) {
const seg = part.split(/\s+/);
const url = seg[0];
const desc = seg[1] ?? '';
let size = -1;
const w = desc.match(/^(\d+)w$/);
const x = desc.match(/^(\d+(?:\.\d+)?)x$/);
if (w) size = Number(w[1]);
else if (x) size = Math.round(Number(x[1]) * 1000);
else size = 0; // 无描述符,通常是最小的兜底,但也可能是唯一候选
if (size >= bestSize) {
bestSize = size;
best = url;
}
}
return best;
}
/**
* Ozon CDN 原图 → wc200 缩略图(侧边栏预览用,省流量)
* 实测结构(reference/ozon1.html):
* https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg
* → https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg
* 已带尺寸标记(/wc\d+/、/c\d+/)或非 multimedia 路径的 URL 原样返回。
*/
export function toThumbUrl(url: string): string {
const m = url.match(/^(https?:\/\/[^/]+\/s3\/[^/]+\/)([^/]+)$/);
if (m && !/\/wc\d+\//.test(url) && !/\/c\d+\//.test(url)) {
return `${m[1]}wc200/${m[2]}`;
}
return url;
}
/** 清洗文件名非法字符(Windows 兼容) */
export function cleanFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.replace(/\s+/g, '_')
.substring(0, 80);
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 1688 采集配置(DOM 兜底路径)
*
* 新版(2026-08 实测,快照:宝宝平衡车)DOM 大改,锚点从业务类名换成稳定的
* id / data-module 属性;旧选择器保留做兼容(旧版页面仍在线上轮转)。
* 主路径(window.context)见 collector/platforms/1688.ts——DOM 只负责兜底
* 和补充参数表(#productAttributes)与详情图(#detail)。
*/
import type { SiteProfile } from './types';
export const profile1688: SiteProfile = {
id: '1688',
name: '1688',
urlPatterns: [/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/],
extractItemId: (url) => url.match(/\/offer\/(\d+)\.html/)?.[1] ?? null,
readySelectors: ['#productTitle', '.title-content', '#detail', '#dt-tab', '#screen', '#content'],
readyTimeoutMs: 10_000,
// 懒加载真实地址在 data-* 上(顺序不能动)
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
refererOrigin: 'https://www.1688.com',
textRules: [
{
kind: 'title',
selectors: [
'#productTitle .title-content', // 新版:data-module="od_title"
'.title-content .title-text', // 旧版:标题拆多个 span,必须 join
'.title-content h1',
'.od-pc-offer-title',
'h1',
],
extract: 'join',
required: true
},
{
kind: 'price',
selectors: ['.price-original', '.od-pc-offer-price-priceRange', '.price .value'],
extract: 'first'
},
// 参数表(新版):#productAttributes 是 antd Descriptions 表格,
// th(键)/td(值) 成对平铺在 tr 里——用 cells 模式取兄弟节点
{
kind: 'params',
selectors: [
'#productAttributes th.ant-descriptions-item-label',
'#productAttributes th',
],
extract: 'cells'
},
// 参数表(旧版):行式键值表
{
kind: 'params',
selectors: [
'.offer-attr-list .offer-attr-item',
'.od-pc-attribute-table tr',
'.obj-content .table-tr'
],
extract: 'table',
tableKeySelector: '.offer-attr-item-name, td:first-child, .table-th',
tableValueSelector: '.offer-attr-item-value, td:last-child, .table-td'
},
{
kind: 'desc',
selectors: ['.de-description-detail', '#detailContentContainer', '.html-description'],
extract: 'join'
}
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
selectors: [
// 新版:模块锚点(data-module / module- 类名)
'[data-module="od_picture_gallery"] img',
'.module-od-picture-gallery img',
// 旧版四套画廊变体
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
'#content .od-scroller-item .v-image-cover',
'#content .od-picture-gallery-list .v-image-cover',
'#dt-tab img',
'.detail-gallery-turn img.detail-gallery-img',
'.img-list-wrapper img.od-gallery-img'
],
activeSelectors: [
'.detail-gallery-turn-wrapper.prepic-active .detail-gallery-img',
'.od-gallery-turn-item-wrapper.prepic-active .od-gallery-img',
'.v-image-cover.image-item-active'
],
minWidth: 200,
minHeight: 200
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
selectors: [
'[data-module="od_sku_selection"] img', // 新版
'.module-od-sku-selection img',
'.pc-sku-wrapper .prop-item-inner-wrapper',
'.sku-item-wrapper',
'.specification-cell',
'.sku-filter-button',
'.expand-view-item',
'.feature-item img'
],
srcProps: ['backgroundImage'],
nameSelectors: ['.prop-name', '.sku-item-name', '.item-label', '.label-name', '.normal-text'],
minWidth: 20,
minHeight: 20
},
{
key: 'detail',
name: '详情图',
type: 'img',
selectors: [
'#detail img', // 新版:详情容器(实测 69 张,含少量图标需过滤)
'.de-description-detail img',
'#detailContentContainer img',
'.html-description img'
],
minWidth: 300,
minHeight: 100
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: ['.lib-video video', 'video']
}
]
};
@@ -0,0 +1,21 @@
/**
* Profile 路由 - 根据 URL 匹配平台(ozon / 1688 / 淘宝 / 天猫)
*/
import type { SiteProfile } from './types';
import { profileOzon } from './ozon';
import { profile1688 } from './1688';
import { profileTaobao } from './taobao';
const PROFILES: SiteProfile[] = [profileOzon, profile1688, profileTaobao];
export function matchProfile(url: string): SiteProfile | null {
for (const p of PROFILES) {
if (p.urlPatterns.some((re) => re.test(url))) {
return p;
}
}
return null;
}
export { profileOzon, profile1688, profileTaobao };
export type { SiteProfile };
+162
View File
@@ -0,0 +1,162 @@
/**
* Ozon 商品页采集配置
*
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html2026-08-15):
* - webProductHeading → <h1> 标题
* - webGallery → 主图(<img srcset>wc50/wc100 缩略图)
* - webAspects → SKU 变体(颜色/尺码选择器)
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state
*
* ★ 主采集路径是 structuredozon-state.ts 读 SSR data-state + JSON-LD + API),
* 本文件的 DOM 选择器只是兜底 + 详情图补充。
*/
import type { SiteProfile } from './types';
export const profileOzon: SiteProfile = {
id: 'ozon',
name: 'Ozon',
urlPatterns: [
// 新版: https://www.ozon.ru/product/slug-123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/product\/[^/]+-\d+\/?/,
// 旧版: https://www.ozon.ru/context/detail/id/123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/context\/detail\/id\/\d+/,
],
extractItemId: (url) => {
const m = url.match(/\/product\/[^/]+-(\d+)\/?/);
if (m?.[1]) return m[1];
const m2 = url.match(/\/context\/detail\/id\/(\d+)/);
return m2?.[1] ?? null;
},
readySelectors: [
'[data-widget="webProductHeading"]',
'[data-widget="webGallery"]',
'h1',
],
readyTimeoutMs: 8_000,
// Ozon 画廊图片是 <img srcset>,懒加载真实地址在 srcset / currentSrc / src
defaultSrcProps: ['srcset', 'currentSrc', 'src', 'data-src'],
refererOrigin: 'https://www.ozon.ru',
textRules: [
{
kind: 'title',
selectors: [
'[data-widget="webProductHeading"] h1',
'h1[itemprop="name"]',
'h1',
],
extract: 'first',
required: true,
},
{
kind: 'price',
selectors: [
'[data-widget="webPrice"] span',
'span[itemprop="price"]',
'[data-widget="webPrice"]',
],
extract: 'first',
},
{
kind: 'params',
selectors: [
'[data-widget="webDetailedCharacteristics"] dl',
'[data-widget="webCharacteristics"] dl',
'[data-widget="webShortCharacteristics"] dl',
'[data-widget="webAspects"] dl',
'#section-characteristics dl',
],
extract: 'table',
tableKeySelector: 'dt, [class*="key"], [class*="Key"], [class*="label"]',
tableValueSelector: 'dd, [class*="value"], [class*="Value"]',
},
{
kind: 'selling_point',
selectors: [
'[data-widget="webShortCharacteristics"]',
'[data-widget="webFeatures"]',
'[data-widget="webAO"]',
],
extract: 'join',
},
{
kind: 'desc',
selectors: [
'[data-widget="webDescription"]',
'[data-widget="webRichContent"]',
'#section-description',
],
extract: 'join',
},
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
selectors: [
'[data-widget="webGallery"] img',
'[data-widget="webGallery"] source',
'[data-widget="webPhotoGallery"] img',
],
// 不设 minWidth:画廊缩略图 naturalWidth 可能很小,原图靠 toOriginalUrl 还原
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
selectors: [
// 实测:变体选择器在 webAspectswebDetailSKU 其实是"复制 SKU"按钮,没有图)
'[data-widget="webAspects"] img',
'[data-widget="webVariants"] img',
],
nameSelectors: [
'span[class*="Value"]',
'span[class*="Text"]',
'span',
],
minWidth: 16,
minHeight: 16,
},
{
key: 'detail',
name: '详情图',
type: 'img',
selectors: [
'[data-widget="webDescription"] img',
'[data-widget="webRichContent"] img',
'[data-widget="webFeatures"] img',
'#section-description img',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: [
'[data-widget="webGallery"] video',
'[data-widget="webVideo"] video',
],
},
],
// 实测 CDNir.ozone.ru):尺寸标记是路径段 /wc\d+/wc50…wc1000)和 /c\d+/c50/c600
// 去掉标记即为原图(页面本身就有无标记的原始 URL)。
originalUrlRules: [
{ match: /\/wc\d+\//, replace: '/' },
{ match: /\/c\d+\//, replace: '/' },
// 去掉尺寸段后路径里会有双斜杠(不动 https:// 的 //
{ match: /(?<!:)\/{2,}/g, replace: '/' },
// 兼容 query 参数形式的尺寸(?width=200&h=300 逐个剥掉)
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
],
};
+143
View File
@@ -0,0 +1,143 @@
/**
* 淘宝 / 天猫采集配置
*
* 选择器全部来自真实页面实测(2026-08-11,两个商品页各跑一轮反向探测):
* 天猫 detail.tmall.com/item.htm?id=960057430812
* 淘宝 item.taobao.com/item.htm?id=1060253247160
* 两站 DOM 完全一致(同一套前端),一份 profile 覆盖。
*
* 类名是 CSS Modules 的 `语义前缀--哈希` 形式,哈希每次构建都变,
* 所以一律用 `[class*="前缀--"]` 前缀匹配。
*
* 结尾那个 `--` 不能省——它把父容器和子元素区分开:
* `generalParamsInfoItem--` 不会误命中 `generalParamsInfoItemTitle--`。
*
* 实测证据见 docs/extension/selectors-taobao.md
*/
import type { SiteProfile } from './types';
export const profileTaobao: SiteProfile = {
id: 'taobao',
name: '淘宝/天猫',
urlPatterns: [
/^https:\/\/item\.taobao\.com\/item\.htm/,
/^https:\/\/detail\.tmall\.com\/item\.htm/,
],
extractItemId: (url) => url.match(/[?&]id=(\d+)/)?.[1] ?? null,
// 页面上没有 <h1>,别再拿它探活
readySelectors: [
'[class*="mainTitle--"]',
'[class*="picGallery--"]',
'#picGalleryEle',
],
readyTimeoutMs: 10_000,
// 阿里系 CDN 规则与 1688 相同
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
refererOrigin: 'https://www.taobao.com',
textRules: [
{
kind: 'title',
// mainTitle-- 是纯文本节点(探测里 imgs=0),最干净
// ItemTitle-- / MainTitle-- 是外层容器,带图标,作兜底
// 注意:属性选择器区分大小写,三个都得写
selectors: [
'[class*="mainTitle--"]',
'[class*="MainTitle--"]',
'[class*="ItemTitle--"]',
],
extract: 'first',
required: true,
},
{
kind: 'price',
// highlightPrice-- 是当前实际售价,两站一致
// priceWrap-- 是外层,会把"优惠前¥36.8"一起带进来,只作兜底
selectors: [
'[class*="highlightPrice--"]',
'[class*="priceWrap--"]',
],
extract: 'first',
},
{
kind: 'params',
// generalParamsInfoItem-- 每项含 Title(键) + SubTitle(值)
selectors: ['[class*="generalParamsInfoItem--"]'],
extract: 'table',
tableKeySelector: '[class*="ParamsInfoItemTitle--"]',
tableValueSelector: '[class*="ParamsInfoItemSubTitle--"]',
},
// desc 故意不采:详情容器 detailInfo-- 里混着用户评价、参数、图文详情,
// join 出来是一坨无法使用的字符串。1688/淘宝的中文文案对 Ozon 价值也低
// (见 docs/extension/1688-taobao-implementation.md 采集优先级)。
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
// picGallery-- 内含大图 + 缩略图,同一张图的两种尺寸
// toOriginalUrl() 剥掉尺寸后缀后 dedupeKey 相同,会自动去重
selectors: [
'[class*="picGallery--"] img',
'#picGalleryEle img',
'[class*="thumbnailPic--"]',
],
// 不设 minWidth:缩略图 naturalWidth 只有 60 左右,
// 按 200 过滤会把主图全误杀(原图靠 toOriginalUrl 还原)
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
// ★ 与 1688 不同:淘宝 SKU 是真实 <img>,不是 CSS 背景图
// 探测证据:valueItem-- n=22 imgs=22(每项恰含一张 img
// 所以这里不能用 srcProps: ['backgroundImage']
selectors: [
'[class*="valueItem--"]',
'[class*="valueItemImgWrap--"]',
],
nameSelectors: ['[class*="valueItemText--"]'],
minWidth: 20,
minHeight: 20,
},
{
key: 'detail',
name: '详情图',
type: 'img',
// 图文详情是懒加载的,需用户点开「图文详情」tab 或滚到底
selectors: [
'[class*="tabDetailWrap--"] img',
'[class*="detailInfo--"] img',
],
// detailInfo-- 同时包着「用户评价」区,买家晒单图能有 400-800px,
// 光靠 minWidth 滤不掉。这些图带水印、质量差,不能采
excludeWithin: [
'[class*="Comment--"]',
'[class*="comments--"]',
'[class*="userInfo--"]',
'[class*="rate"]',
// 本店推荐:详情区底部的推荐卡片流(RecommendInfo-- 容器 / data-spm="recommends" /
// recommend-- 卡片区 / cardPic-- 卡片图盒),不是本商品的详情图,不能采
'[class*="RecommendInfo--"]',
'[data-spm="recommends"]',
'[class*="recommend--"]',
'[class*="cardPic--"]',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: ['[class*="picGallery--"] video', 'video'],
},
],
};
@@ -0,0 +1,79 @@
/**
* Site Profile - 平台采集配置(声明式)
*
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
* selling_point(卖点 / About this item)、brand(品牌)。
*
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
*/
export type TextKind =
| 'title'
| 'price'
| 'params'
| 'selling_point'
| 'desc'
| 'brand'
| 'sales'
| 'shop';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video' | 'upload';
export type SrcProp =
| 'data-lazyload-src'
| 'data-src'
| 'srcset'
| 'currentSrc'
| 'src'
| 'backgroundImage';
export interface TextRule {
kind: TextKind;
/** 多套选择器,逐个尝试直到命中 */
selectors: string[];
/**
* extract 模式:
* join - 所有命中节点的文本拼接(标题被拆多个 span 时用)
* first - 只取第一个命中节点
* table - 行式键值表:selectors 命中行,tableKey/ValueSelector 在行内取键值
* cells - 兄弟键值对:selectors 直接命中「键」节点,值取它的下一个兄弟元素
* (适配 antd Descriptions 的 th/td 结构、dl 的 dt/dd 结构)
*/
extract: 'join' | 'first' | 'table' | 'cells';
/** table 模式的 key/value 子选择器 */
tableKeySelector?: string;
tableValueSelector?: string;
required?: boolean;
}
export interface ImageGroupRule {
key: ImageGroupKey;
name: string;
type: 'img' | 'video';
selectors: string[];
/** 覆盖 defaultSrcProps */
srcProps?: SrcProp[];
/** SKU 规格名来源 */
nameSelectors?: string[];
/** 画廊"当前高亮"元素(排除) */
activeSelectors?: string[];
/** 位于这些容器内的图片一律跳过(el.closest 判断) */
excludeWithin?: string[];
minWidth?: number;
minHeight?: number;
}
export interface SiteProfile {
id: string;
name: string;
urlPatterns: RegExp[];
extractItemId: (url: string) => string | null;
readySelectors: string[];
readyTimeoutMs?: number;
defaultSrcProps: SrcProp[];
textRules: TextRule[];
imageGroups: ImageGroupRule[];
/** 图片 URL 还原原图规则(缺省用通用 CDN 后缀规则) */
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
refererOrigin?: string;
}
@@ -0,0 +1,77 @@
/**
* 服务端设置(上传/生成用):后端地址 + Bearer Token + 水印选项,持久化到 chrome.storage.local。
* v2.1 并入 ozon-seller-kit 后新增「商品上报」配置:reportEnabled / reportBaseUrl / studioBaseUrl。
*/
/** 生成图水印:服务端在 AI 出图后、落盘前合成(与生图模型无关)。默认复刻 ozonSeller。 */
export interface WatermarkSettings {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number; // 1-100%
}
export interface BackendSettings {
baseUrl: string;
token: string;
watermark: WatermarkSettings;
/** 商品上报(ozon-seller-kit 后台):开关 + 后台地址 + 试算页地址 */
reportEnabled: boolean;
reportBaseUrl: string;
studioBaseUrl: string;
}
const KEY = 'suite_backend_settings';
export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
export const DEFAULT_REPORT_BASE_URL = 'http://127.0.0.1:8800';
export const DEFAULT_STUDIO_BASE_URL = 'http://localhost:8900';
const DEFAULT: BackendSettings = {
baseUrl: DEFAULT_BASE_URL,
token: '',
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 },
reportEnabled: true,
reportBaseUrl: DEFAULT_REPORT_BASE_URL,
studioBaseUrl: DEFAULT_STUDIO_BASE_URL,
};
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */
const MIGRATE: Record<string, string> = {
'http://127.0.0.1:8810': DEFAULT_BASE_URL,
'http://127.0.0.1:7000': DEFAULT_BASE_URL,
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
'http://localhost:7000': DEFAULT_BASE_URL,
'http://localhost:7200': DEFAULT_BASE_URL,
'http://localhost:3300': DEFAULT_BASE_URL,
};
export async function loadSettings(): Promise<BackendSettings> {
const r = await chrome.storage.local.get(KEY);
const saved = r[KEY] ?? {};
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
// 水印子对象深合并:老版本存储里没有 watermark,避免整对象覆盖丢默认值
const s: BackendSettings = {
token: '',
...saved,
baseUrl,
watermark: { ...DEFAULT.watermark, ...(saved.watermark ?? {}) },
// 上报配置为 v2.1 新增字段:老存档缺失时兜底默认值
reportEnabled: saved.reportEnabled ?? DEFAULT.reportEnabled,
reportBaseUrl: saved.reportBaseUrl ?? DEFAULT.reportBaseUrl,
studioBaseUrl: saved.studioBaseUrl ?? DEFAULT.studioBaseUrl,
};
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
return s;
}
export async function saveSettings(s: BackendSettings): Promise<void> {
// localhost 会被 Chrome 解析为 IPv6 ::1,若该端口被系统服务(如 macOS AirPlay)占用会 403
// 统一改写为 IPv4 的 127.0.0.1
s = {
...s,
baseUrl: s.baseUrl.replace('//localhost:', '//127.0.0.1:'),
reportBaseUrl: s.reportBaseUrl.replace('//localhost:', '//127.0.0.1:'),
};
await chrome.storage.local.set({ [KEY]: s });
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules", ".output"]
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: '电商套图工作台',
description: '采集 Ozon / 1688 / 淘宝 / 天猫 商品信息与图片,一键生成电商套图并导出',
permissions: [
'storage',
'sidePanel',
'activeTab',
'scripting', // 执行 content script 函数需要
'downloads' // 导出采集图片 / 套图 ZIP 到本地
],
host_permissions: [
// Ozon 商品页 + 图片 CDN
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://*.ozonusercontent.com/*',
// 1688 / 淘宝 / 天猫 + 阿里 CDN
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://*.alicdn.com/*',
// 1688 详情数据 CDNdescription.detailUrl
'https://itemcdn.tmall.com/*',
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
'http://127.0.0.1:3300/*',
'http://localhost:3300/*',
// ozon-seller-kit 后台(商品上报 8800+ 试算页(8900)
'http://127.0.0.1:8800/*',
'http://localhost:8800/*',
'http://127.0.0.1:8900/*',
'http://localhost:8900/*'
],
action: {
default_title: '电商套图工作台'
},
// 页内悬浮面板用 iframe 加载 sidepanel.html,必须声明为 web accessible
web_accessible_resources: [{
resources: ['sidepanel.html'],
matches: [
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
}]
},
modules: ['react']
});
+121
View File
@@ -0,0 +1,121 @@
"""导出采集图片:把勾选的采集图/生成图打包成 ZIP 下载到本地。
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
文件名沿用采集 keymain-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
"""
from __future__ import annotations
import io
import mimetypes
import re
import zipfile
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from api.proxy import guess_referer
from services.storage import download_bytes, local_path
router = APIRouter(prefix="/api", tags=["export"])
_EXT_BY_CTYPE = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/bmp": ".bmp",
}
class ExportImageItem(BaseModel):
url: str
groupName: str = "主图"
variantName: str | None = None
key: str = "" # 采集 key,如 main-001 / sku-002 / upload-001
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem]
def _clean(name: str) -> str:
"""清洗文件夹/文件名非法字符(与插件 cleanFilename 同规则,Windows 兼容)。"""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", (name or "").strip())
s = re.sub(r"\s+", "_", s)
return s.strip(" .")[:80]
def _ext(url: str, ctype: str) -> str:
"""由 content-type(优先)或 URL 后缀决定扩展名。"""
ctype = ctype.split(";")[0].strip().lower()
if ctype in _EXT_BY_CTYPE:
return _EXT_BY_CTYPE[ctype]
if ctype.startswith("image/"):
return "." + ctype.split("/")[-1]
path = url.split("?")[0].lower()
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"):
if path.endswith(ext):
return ".jpg" if ext == ".jpeg" else ext
return ".jpg"
def _is_image(url: str, ctype: str) -> bool:
ctype = ctype.split(";")[0].strip().lower()
if ctype.startswith("image/"):
return True
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
async def _download(url: str) -> tuple[bytes, str]:
"""本地 media 文件直读磁盘;远程 URL(源站 CDN / 七牛)带 Referer 下载。"""
path = local_path(url)
if path is not None:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
return path.read_bytes(), mime
return await download_bytes(url, referer=guess_referer(url))
@router.post("/export/images")
async def export_images(req: ExportImagesRequest):
if not req.images:
raise HTTPException(status_code=400, detail="没有可导出的图片")
root = _clean(req.title) or "采集图片"
buf = io.BytesIO()
used: set[str] = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for img in req.images:
try:
data, ctype = await _download(img.url)
except Exception: # noqa: BLE001
continue # 单张失败不中断整包
if not _is_image(img.url, ctype):
continue
ext = _ext(img.url, ctype)
base = _clean(img.key) or "image"
if img.variantName:
base += f"-{_clean(img.variantName)}"
filename = f"{base}{ext}"
if filename in used: # 同名加序号防覆盖
stem = filename[: -len(ext)]
n = 2
while f"{stem}-{n}{ext}" in used:
n += 1
filename = f"{stem}-{n}{ext}"
used.add(filename)
group = _clean(img.groupName) or "图片"
zf.writestr(f"{root}/{group}/{filename}", data)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="collect.zip"'},
)
+54
View File
@@ -0,0 +1,54 @@
"""图片代理:绕过源站防盗链,供前端 <img> 预览与生图参考使用。
平移自 image-suite-studio/api/proxy-image?url=... 形式,docs/v2.1/api.md §7)。
"""
from __future__ import annotations
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Response
from services.storage import download_bytes
router = APIRouter(prefix="/api", tags=["proxy"])
# 域名片段 → 防盗链所需 Referer
_REFERER_BY_DOMAIN: list[tuple[str, str]] = [
("alicdn.com", "https://www.taobao.com"),
("taobao.com", "https://www.taobao.com"),
("tmall.com", "https://www.tmall.com"),
("1688.com", "https://www.1688.com"),
("ozon.ru", "https://www.ozon.ru"),
("ozon.kz", "https://www.ozon.ru"),
("ozon.by", "https://www.ozon.ru"),
("ozonusercontent.com", "https://www.ozon.ru"),
]
def guess_referer(url: str) -> str | None:
host = (urlparse(url).hostname or "").lower()
for frag, referer in _REFERER_BY_DOMAIN:
if frag in host:
return referer
return None
@router.get("/proxy-image")
async def proxy_image(url: str = Query(..., description="源站图片 URL")):
scheme = urlparse(url).scheme
if scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
try:
data, ctype = await download_bytes(url, referer=guess_referer(url))
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
if not ctype.startswith("image/"):
ctype = "image/jpeg"
return Response(
content=data,
media_type=ctype,
headers={
"Cache-Control": "public, max-age=86400",
"Access-Control-Allow-Origin": "*",
},
)
+320
View File
@@ -0,0 +1,320 @@
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studioservices/planner|generator|
tasks|watermark + services/prompts),任务存进程内内存表(重启即失效)。
生成图回调回写 product_assets(group_key='generated')product_id 缺省时只落盘不回写。
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
"""
from __future__ import annotations
import io
import mimetypes
import uuid
import zipfile
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse
from config import get_settings
from db import get_session_factory
from models import Product, ProductAsset
from schemas.suite import (
PLATFORM_SPECS,
RIGHTAPI_MODELS,
SUPPORTED_TYPES,
TONGYI_MODELS,
ImageEditSingleRequest,
ImageEditSingleResponse,
PlanItemOut,
PlanResponse,
SuiteCreateResponse,
SuiteGenerateRequest,
SuiteOut,
SuitePlanRequest,
TextMaterial,
resolve_provider,
)
from services.generator import _image_size, GENERATORS, run_suite
from services.planner import generate_plan
from services.prompts import build_context, build_prompt, type_name
from services.storage import download_bytes, get_storage, local_path
from services.tasks import IMG_OK, Task, TaskImage, create_task, get_task
from api.proxy import guess_referer
router = APIRouter(prefix="/api", tags=["suite"])
def texts_to_raw(texts: list[TextMaterial]) -> dict:
"""前端组装的文本素材 → prompt 上下文用的 raw dict(后写的覆盖先写的)。"""
raw: dict = {}
for t in texts:
if t.kind == "title" and t.content:
raw["title"] = t.content
elif t.kind == "price" and t.content:
raw["price"] = t.content
elif t.kind == "brand" and t.content:
raw["brand"] = t.content
elif t.kind == "params" and t.pairs:
merged = {p["key"]: p["value"] for p in (raw.get("params") or [])}
for p in t.pairs:
merged.setdefault(p["key"], p["value"])
raw["params"] = [{"key": k, "value": v} for k, v in merged.items()]
elif t.kind == "selling_point" and t.content:
raw["sellingPoints"] = t.content
elif t.kind == "desc" and t.content:
raw["desc"] = t.content
elif t.kind == "sales" and t.content:
raw["sales"] = t.content
elif t.kind == "shop" and t.content:
raw["shop"] = t.content
return raw
def _validate_model(provider_name: str, model: str | None) -> None:
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}tongyi 支持: {TONGYI_MODELS}")
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}")
# ── 生成图回写商品素材 ────────────────────────────────────────────────────
async def _append_generated_asset(product_id: str, image: TaskImage) -> str | None:
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。"""
from sqlalchemy import func, select
pid = uuid.UUID(product_id)
async with get_session_factory()() as db:
count = await db.scalar(
select(func.count(ProductAsset.id)).where(
ProductAsset.product_id == pid,
ProductAsset.group_key == "generated",
)
)
asset = ProductAsset(
product_id=pid,
group_key="generated",
variant_name=None,
sort_order=count or 0,
type="img",
source_url="",
stored_url=image.url,
status="uploaded",
)
db.add(asset)
await db.flush()
product = await db.get(Product, pid)
if product is not None:
counts = dict(product.asset_counts or {})
counts["generated"] = int(counts.get("generated") or 0) + 1
product.asset_counts = counts
await db.commit()
return str(asset.id)
# ── 出图方案规划 ──────────────────────────────────────────────────────────
@router.post("/suite/plan", response_model=PlanResponse)
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
product_info = texts_to_raw(req.texts)
if not product_info.get("title"):
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
try:
result = await generate_plan(
product_info, req.sku_variants, req.image_stats, req.platform,
requirements=req.requirements,
)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"规划失败: {exc}") from exc
return PlanResponse(summary=result["summary"], items=[PlanItemOut(**i) for i in result["items"]])
# ── 一键生成 ─────────────────────────────────────────────────────────────
@router.post("/suite/generate", response_model=SuiteCreateResponse)
async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks) -> SuiteCreateResponse:
if not req.images:
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
# 生成任务列表:方案优先;无方案时按 types(空则默认四种)
if req.plan:
jobs: list[dict] = []
for item in req.plan:
if item.count <= 0:
continue
if item.kind not in SUPPORTED_TYPES:
raise HTTPException(status_code=400, detail=f"方案项「{item.title}」的图类型不支持: {item.kind}")
# 同一项多张 → 展开为多任务,第二张起在标题上加序号
for n in range(item.count):
jobs.append({
"kind": item.kind,
"title": item.title if item.count == 1 else f"{item.title}{n + 1}",
"detail": item.detail,
"prompt_hint": item.prompt_hint,
"variant_name": item.variant_name,
})
if not jobs:
raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0")
else:
types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"]
bad = [t for t in types if t not in SUPPORTED_TYPES]
if bad:
raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}")
jobs = [{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None} for t in types]
if req.platform not in PLATFORM_SPECS:
raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}ozon | wb | cn")
spec = PLATFORM_SPECS[req.platform]
settings = get_settings()
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi
provider_name = resolve_provider(req.model, None, settings.image_provider)
_validate_model(provider_name, req.model)
product_id = (req.product_id or "").strip() or None
if product_id:
try:
uuid.UUID(product_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
task = create_task(
status="pending",
platform=req.platform,
lang=spec["lang"],
ratio=spec["ratio"],
style_set=req.style_set,
style_prompt=req.style_prompt,
requirements=req.requirements,
provider=provider_name,
model=req.model,
total=len(jobs),
context=texts_to_raw(req.texts),
plan=jobs,
watermark=req.watermark.model_dump() if req.watermark else None,
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[
{
"url": i.url,
"group_key": i.group_key,
"variant_name": i.variant_name,
}
for i in sorted(req.images, key=lambda x: 0 if x.group_key == "main" else 1)
],
)
# 每张成功即回写 generated 组;未关联商品时仅落存储不回写
background.add_task(run_suite, task, _append_generated_asset if product_id else None)
return SuiteCreateResponse(suite_id=task.id)
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
@router.get("/suites/{suite_id}", response_model=SuiteOut)
async def get_suite(suite_id: str):
task = get_task(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成")
return SuiteOut(
id=task.id,
status=task.status,
style_set=task.style_set,
platform=task.platform,
lang=task.lang,
ratio=task.ratio,
provider=task.provider,
model=task.model,
total=task.total,
images=[
{"type_id": i.type_id, "name": i.name, "url": i.url, "status": i.status, "error": i.error}
for i in task.images
],
error=task.error,
)
@router.get("/suites/{suite_id}/zip")
async def download_suite_zip(suite_id: str):
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
task = get_task(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
seen: set[str] = set()
for i, img in enumerate([i for i in task.images if i.status == IMG_OK]):
path = local_path(img.url or "")
if path is not None:
data, suffix = path.read_bytes(), path.suffix or ".jpg"
else:
try: # 七牛等远程存储:直接拉取公开 URL
data, _ = await download_bytes(img.url or "")
except Exception: # noqa: BLE001
continue
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
filename = img.name or img.type_id
if filename in seen: # 同类型多张时加序号防覆盖
filename = f"{filename}-{i + 1}"
seen.add(filename)
zf.writestr(f"{filename}{suffix}", data)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
)
# ── 单张 AI 生图(V2.1 新增,ISS 无对应实现)──────────────────────────────
@router.post("/suite/image-edit", response_model=ImageEditSingleResponse)
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleResponse:
"""轻量单张再生成:原图 + 一句话要求 + 模型选择。同步接口(单张可长达数分钟)。"""
settings = get_settings()
provider_name = resolve_provider(req.model, None, settings.image_provider)
if provider_name not in GENERATORS:
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
_validate_model(provider_name, req.model)
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
model = req.model
is_wan = provider_name == "tongyi" and model.lower().startswith("wan")
# 复用套图的 prompt 家族:custom 类型装配「用户要求」为最高优先级约束,
# 商品外观仍由参考图锁定(保真语义与一键生成一致)。
ctx = build_context({"title": "AI 精修"}, fallback_name="product")
prompt = build_prompt(
provider_name, model, "custom", ctx,
style_set=5, lang=spec["lang"],
extra={"title": "AI 精修", "detail": req.prompt, "prompt_hint": ""},
requirements=req.prompt,
)
try:
generator = GENERATORS[provider_name]
data = await generator(prompt, [req.image_url], size=_image_size(provider_name, spec["ratio"], is_wan=is_wan, model=model), model=model)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"生成失败: {exc}") from exc
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
ext = ".png" if is_png else ".jpg"
ctype = "image/png" if is_png else "image/jpeg"
url = await get_storage().save_bytes(data, f"suites/single/{uuid.uuid4().hex}{ext}", ctype)
asset_id: str | None = None
if req.append and req.product_id:
try:
asset_id = await _append_generated_asset(
req.product_id,
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
)
except Exception: # noqa: BLE001
pass # 回写失败不影响结果返回,图片已在任务网格可见
return ImageEditSingleResponse(url=url, asset_id=asset_id)
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+27
View File
@@ -50,6 +50,33 @@ class Settings(BaseSettings):
# ── V2:对外地址(插件/前端回写、生成图回调)──
app_base_url: str = "http://127.0.0.1:8800"
# ── V2.1 套图生图(provider 路由与 image-suite-studio 一致)──
image_provider: str = "doubao" # 未知模型时的兜底 providerdoubao | tongyi | rightapi
request_timeout: int = 300 # 单张生图请求超时(秒)
poll_max_wait: int = 600 # 异步任务轮询上限(秒)
# 水印徽章源图(图片水印用,可 .env 覆盖)
watermark_image_path: str = str(Path(__file__).resolve().parents[1] / "assets" / "watermark.jpg")
# 豆包 / 火山方舟 Seedream
ark_api_key: str = ""
ark_base_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
ark_image_model: str = "doubao-seedream-4-5-251128"
# 通义 / DashScopekey 复用上方 dashscope_api_keydashscope_base_http_api_url 是业务空间专用,与此处无关)
dashscope_model: str = "wan2.7-image-pro"
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
# RightAPIOpenAI 兼容中转:gpt-image / nano-banana 系列)
rightapi_api_key: str = ""
rightapi_base_url: str = "https://rightapi.ai/draw"
rightapi_image_model: str = "gpt-image-2"
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
# DeepSeek 出图方案规划器(key 复用上方 deepseek_api_key;文案生成的模型走 config/models.yaml,互不影响)
deepseek_base_url: str = "https://api.deepseek.com/v1"
deepseek_model: str = "deepseek-v4-flash"
@property
def cors_origin_list(self) -> list[str]:
if not self.cors_origins.strip():
+5 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from api import ai, auth, categories, collection, fx, image, ozon, products, publish, shops
from api import ai, auth, categories, collection, export, fx, image, ozon, products, proxy, publish, shops, suite
from config import get_settings
from db import get_engine
@@ -37,6 +37,10 @@ app.include_router(fx.router)
app.include_router(ai.router)
app.include_router(image.router)
app.include_router(ozon.router)
# V2.1 套图生图(试算页 04 区块)/ 导出 / 图片代理
app.include_router(suite.router)
app.include_router(export.router)
app.include_router(proxy.router)
@app.on_event("startup")
+3
View File
@@ -14,3 +14,6 @@ alembic>=1.13.0
PyJWT>=2.8.0
cryptography>=42.0.0
qiniu>=7.13.0
# V2.1:套图生成(水印合成)
pillow>=10.0.0
+160
View File
@@ -0,0 +1,160 @@
"""套图规划 / 一键生成 / 单张 AI 生图 契约(studio/src/services/suite.ts 同源,见 docs/v2.1/api.md)。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
SUPPORTED_TYPES = [
"white_bg", "key_features", "selling_pt", "material",
"lifestyle", "multi_scene", "ecommerce_detail",
"size_chart", "sku_collection", "custom",
]
# 通义(DashScope)生图模型白名单
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"]
# RightAPI 生图模型白名单(gpt-image 系列 + Google nano-banana 系列,同一中转)
RIGHTAPI_MODELS = [
"gpt-image-2",
"gpt-image-2-vip",
"nano-banana",
"nano-banana-2",
"nano-banana-2-lite",
"nano-banana-pro",
]
# 模型 → provider 推断表:前端只传模型名,服务端据此路由(模型名优先于 provider 字段)
MODEL_PROVIDERS: dict[str, str] = {
**{m: "tongyi" for m in TONGYI_MODELS},
**{m: "rightapi" for m in RIGHTAPI_MODELS},
}
# 豆包 Seedream 走火山方舟;模型由 ARK_IMAGE_MODEL 指定(不在下拉白名单内也允许直连豆包)
DOUBAO_MODELS = ["doubao-seedream-4-5-251128"]
MODEL_PROVIDERS.update({m: "doubao" for m in DOUBAO_MODELS})
def resolve_provider(model: str | None, requested: str | None, default: str) -> str:
"""已知模型名直接定位 provider;未知模型回退到请求指定的 provider 或默认值。"""
if model and model in MODEL_PROVIDERS:
return MODEL_PROVIDERS[model]
return requested or default
# 目标平台 → 文案语言 + 图片比例(平台决定规格,不再单独选语言)
PLATFORM_SPECS: dict[str, dict] = {
"ozon": {"lang": "ru", "ratio": "3:4", "label": "Ozon"},
"wb": {"lang": "ru", "ratio": "3:4", "label": "Wildberries"},
"cn": {"lang": "zh", "ratio": "1:1", "label": "中文(国内平台)"},
}
class TextMaterial(BaseModel):
kind: str = Field(..., description="title | params | selling_point | desc | price | brand | sales | shop")
content: str = ""
pairs: list[dict] | None = None # [{key, value}]
class WatermarkOptions(BaseModel):
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。右下角,默认文字 xiongmaoyx。"""
enabled: bool = Field(default=False, description="是否开启水印")
type: Literal["image", "text"] = Field(default="image", description="图片水印 | 文字水印")
text: str = Field(default="xiongmaoyx", description="文字水印内容")
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%")
class PlanItem(BaseModel):
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
title: str = Field(..., description="方案标题,如「主图·粉色」")
detail: str = Field(default="", description="这张图展示什么(中文)")
prompt_hint: str = Field(default="", description="构图提示(英文,进生图 prompt")
count: int = Field(default=1, ge=0, le=5)
variant_name: str | None = Field(default=None, description="绑定的 SKU 规格名")
class SuitePlanRequest(BaseModel):
"""POST /api/suite/planDeepSeek 根据商品信息生成出图方案。"""
product_id: str | None = Field(default=None, description="便于日志与上下文定位,可空")
texts: list[TextMaterial] = Field(default_factory=list)
sku_variants: list[str] = Field(default_factory=list, description="带图的 SKU 规格名")
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
platform: str = Field(default="ozon")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
class PlanItemOut(BaseModel):
kind: str
title: str
detail: str = ""
prompt_hint: str = ""
count: int = 1
variant_name: str | None = None
class PlanResponse(BaseModel):
summary: str = ""
items: list[PlanItemOut]
class GenerateImageItem(BaseModel):
url: str = Field(..., description="勾选的参考底图 URL(stored_url 或源站原图)")
group_key: str = Field(default="main", description="main | sku | detail | upload")
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
class SuiteGenerateRequest(BaseModel):
"""POST /api/suite/generate:提交套图任务,返回 suite_id 后前端轮询。"""
product_id: str | None = Field(default=None, description="用于生成图回写素材(generated 组),可空")
texts: list[TextMaterial] = Field(default_factory=list)
images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考底图")
style_set: int = Field(default=1, ge=1, le=5)
style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)")
plan: list[PlanItem] | None = Field(default=None, description="出图方案(优先于 types")
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
platform: str = Field(default="ozon")
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider")
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
class SuiteCreateResponse(BaseModel):
suite_id: str
class SuiteImageOut(BaseModel):
type_id: str
name: str
url: str
status: str
error: str | None = None
class SuiteOut(BaseModel):
id: str
status: str
style_set: int
platform: str
lang: str
ratio: str
provider: str
model: str | None = None
total: int = 0 # 计划总张数(进度分母;images 是逐张追加,过程中 length < total
images: list[SuiteImageOut]
error: str | None = None
class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
prompt: str = Field(..., min_length=1, description="用户要求(必填,进生图指令)")
model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
class ImageEditSingleResponse(BaseModel):
url: str
asset_id: str | None = None
+494
View File
@@ -0,0 +1,494 @@
"""套图生成服务:图像 provider(豆包 Seedream / 通义万相 / RightAPI 中转)+ 任务执行器。
平移自 image-suite-studio/services/generator.py,适配点:
- 存储走本仓 services/storage 抽象(本地 data/media 兜底 / 七牛),save_bytes 直接返回可访问 URL
- 参考图解析复用 storage.local_path 与 storage.download_bytes(防盗链 Referer 由 api/proxy.guess_referer 提供);
- run_suite 新增可选 on_image_ok 回调:每张成功即通知调用方落库(product_assets/generated),
本模块不接触数据库,保持「套图引擎不依赖商品模型」的独立产品化裁剪能力。
"""
from __future__ import annotations
import asyncio
import base64
import logging
import mimetypes
import re
from typing import Awaitable, Callable
import httpx
from config import get_settings
from services.prompts import build_context, build_prompt, type_name
from services.tasks import (
IMG_FAILED,
IMG_OK,
TASK_DONE,
TASK_FAILED,
TASK_PARTIAL,
TASK_RUNNING,
Task,
TaskImage,
)
from services.watermark import apply_watermark
from services.storage import download_bytes, get_storage, local_path
log = logging.getLogger("suite.generator")
# 单张成功回调:(image) -> None;由 API 层注入用于回写商品素材
OnImageOk = Callable[[TaskImage], Awaitable[None]]
class ApiError(RuntimeError):
"""带 HTTP 状态码的 API 错误(用于区分可重试的网关/限流错误)。"""
def __init__(self, message: str, status: int = 0):
super().__init__(message)
self.status = status
_HTML_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def _raise_api_error(resp, provider: str):
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
if resp.is_success:
return
text = resp.text or ""
if "<html" in text[:300].lower() or text.lstrip()[:15].lower().startswith("<!doctype"):
# Cloudflare/网关错误页:取 <title> 作摘要,避免整段 HTML 进错误信息
m = _HTML_TITLE_RE.search(text)
detail = (re.sub(r"\s+", " ", m.group(1)).strip() if m else "") or "网关返回 HTML 错误页(上游/CDN 故障)"
raise ApiError(f"{provider} API HTTP {resp.status_code}{detail}", resp.status_code)
try:
body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001
detail = text[:200]
raise ApiError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}", resp.status_code)
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
TYPE_REF_INDEX = {
"material": 1,
}
DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张)
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
if provider == "doubao":
return "1536x2048" if ratio == "3:4" else "2048x2048"
if provider == "rightapi":
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²]wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
return "1152*1536" if ratio == "3:4" else "1440*1440"
if ratio == "3:4":
return "1536*2048" if is_wan else "768*1024"
return "2048*2048" if is_wan else "1024*1024"
_DOUBAO_ANTI_AI = (
"authentic real-world photography, natural imperfections, genuine texture, "
"no synthetic look, no CGI quality, no heavy post-processing"
)
DEFAULT_NEGATIVE_PROMPT = (
"AI-generated look, artificial, CGI quality, 3D render, synthetic texture, "
"plastic skin, mannequin-like, too perfect, oversaturated, HDR, heavy vignette, "
"low resolution, blurry, deformed, bad anatomy, overexposed, underexposed, grainy, "
"watermark, text distortion, bad typography, overlapping text, cheap look, cartoon"
)
# ── 参考图解析 ────────────────────────────────────────────────────────────
def _bytes_to_data_uri(data: bytes, mime: str) -> str:
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]:
"""参考图 URL → (bytes, mime)。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传,
所以统一在本地解析成原始字节再进请求体(data URI 或 multipart)。
"""
if url.startswith("data:"):
head, _, b64 = url.partition(",")
mime = head[5:].split(";", 1)[0] or "image/jpeg"
return base64.b64decode(b64), mime
path = local_path(url)
if path is not None:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
return path.read_bytes(), mime
if url.startswith(("http://", "https://")):
from api.proxy import guess_referer
data, ctype = await download_bytes(url, referer=guess_referer(url))
if not ctype.startswith("image/"):
ctype = "image/jpeg"
return data, ctype
raise FileNotFoundError(f"无法解析参考图: {url}")
async def _resolve_ref(url: str) -> str:
data, mime = await _resolve_ref_bytes(url)
return _bytes_to_data_uri(data, mime)
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
s = get_settings()
if not s.ark_api_key:
raise RuntimeError("未配置 ARK_API_KEY.env")
body = {
"model": model or s.ark_image_model,
"prompt": prompt.rstrip(". ") + ". " + _DOUBAO_ANTI_AI,
"size": size,
"response_format": "url",
"watermark": False,
"n": 1,
}
if ref_images:
body["image"] = [await _resolve_ref(u) for u in ref_images]
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
resp = await client.post(
s.ark_base_url,
headers={"Authorization": f"Bearer {s.ark_api_key}", "Content-Type": "application/json"},
json=body,
)
_raise_api_error(resp, "豆包")
img_url = resp.json()["data"][0]["url"]
dl = await client.get(img_url, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
# ── Provider:通义万相 / 千问(DashScope)────────────────────────────────
def _is_wan_model(model: str) -> bool:
return model.lower().startswith("wan")
def _is_t2i_model(model: str) -> bool:
"""纯文生图模型(如 wan2.6-t2i):不接受参考图,商品一致性只能靠文案描述。"""
return "t2i" in model.lower()
async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, max_wait: int) -> str:
poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id
elapsed, interval = 0, 3
while elapsed < max_wait:
resp = await client.get(poll_url, headers={"Authorization": f"Bearer {key}"}, timeout=30)
resp.raise_for_status()
result = resp.json()
status = result.get("output", {}).get("task_status", "")
if status == "SUCCEEDED":
choices = result["output"].get("choices", [])
if choices:
content = choices[0].get("message", {}).get("content", [])
if content:
return content[0].get("image", "")
results = result["output"].get("results", [])
if results:
return results[0].get("url") or results[0].get("b64_image", "")
raise RuntimeError(f"通义任务成功但无结果: {result}")
if status in ("FAILED", "UNKNOWN"):
raise RuntimeError(f"通义任务失败: {result}")
await asyncio.sleep(interval)
elapsed += interval
interval = min(interval + 2, 10)
raise TimeoutError(f"通义异步任务超时 ({max_wait}s): task_id={task_id}")
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048", model: str | None = None) -> bytes:
s = get_settings()
if not s.dashscope_api_key:
raise RuntimeError("未配置 DASHSCOPE_API_KEY.env")
model = model or s.dashscope_model
is_wan = _is_wan_model(model)
url = s.dashscope_base_url or (
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
if is_wan
else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
)
# t2i 模型不接受参考图:content 只有文本,商品一致性依赖 prompt 里的标题/卖点描述
content: list[dict] = []
if not _is_t2i_model(model):
content = [{"image": await _resolve_ref(u)} for u in ref_images]
content.append({"text": prompt})
params = {"size": size, "n": 1, "watermark": False}
if not is_wan:
params["prompt_extend"] = False
params["negative_prompt"] = DEFAULT_NEGATIVE_PROMPT[:500]
headers = {"Authorization": f"Bearer {s.dashscope_api_key}", "Content-Type": "application/json"}
if is_wan:
headers["X-DashScope-Async"] = "enable"
body = {"model": model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
resp = await client.post(url, headers=headers, json=body)
_raise_api_error(resp, "通义")
data = resp.json()
if is_wan:
task_id = data.get("output", {}).get("task_id", "")
if not task_id:
raise RuntimeError(f"通义万象未返回 task_id: {data}")
img_url = await _tongyi_poll_task(client, s.dashscope_api_key, task_id, s.poll_max_wait)
if img_url.startswith("data:") or len(img_url) > 500:
return base64.b64decode(img_url.split(",", 1)[-1] if "," in img_url else img_url)
dl = await client.get(img_url, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
img_url = data["output"]["choices"][0]["message"]["content"][0]["image"]
dl = await client.get(img_url, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
# ── ProviderRightAPIgpt-image / nano-bananaOpenAI 兼容中转)──────────
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str,
task_id: str, max_wait: int) -> dict:
"""轮询站点级任务查询接口 GET /v1/tasks/{task_id}(不带 /draw 前缀)。
实测要点:
- 完成响应**没有** status:"completed" 字段,完成判定 = 响应里出现 data;
- progress 基本不动(0~2),不能当进度条依据;
- 失败态 = status 为 failed / error / cancelled。
"""
poll_url = f"{origin}/v1/tasks/{task_id}"
elapsed, interval = 0, 3
while elapsed < max_wait:
resp = await client.get(poll_url, headers=headers, timeout=30)
_raise_api_error(resp, "RightAPI")
result = resp.json()
status = result.get("status", "")
if status in ("failed", "error", "cancelled"):
err = result.get("error") or {}
raise RuntimeError(f"RightAPI 任务失败: {err.get('message') or result}")
if "data" in result:
return result
await asyncio.sleep(interval)
elapsed += interval
interval = min(interval + 2, 10)
raise TimeoutError(f"RightAPI 异步任务超时 ({max_wait}s): task_id={task_id}")
def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]:
"""从轮询完成结果里取 (kind, payload)kind ∈ url | b64,未取到返回 (None, None)。
完成形状为 Images 协议:{"created":..., "data":[{"url": "..."}]}(实测只见 url)。
"""
data = result.get("data") or []
if data:
item = data[0] or {}
url = item.get("url") or ""
if url:
return ("url", url)
b64 = item.get("b64_json") or ""
if b64:
return ("b64", b64)
return (None, None)
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""RightAPI 各模型:统一走 /v1/images/generations(异步)。
官方协议(2026-07 起统一异步):
- POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI);
- 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw);
- 参数只有 model/prompt/n/size/imageSize/image/async;不传 quality/output_format/input_fidelity。
参考图沿用现选图逻辑(≤2 张,image 数组)。单张 1-5 分钟,轮询上限 poll_max_wait 兜底。
"""
base = s.rightapi_base_url.rstrip("/")
# 任务查询是站点级接口,不带 /draw:从 base 里拆出 originhttps://rightapi.ai/draw → https://rightapi.ai
origin = base.split("/draw", 1)[0].rstrip("/") or base
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
body = {
"model": model,
"prompt": prompt,
"n": 1,
"size": size,
"async": True,
}
if ref_images:
body["image"] = [await _resolve_ref(u) for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
resp = await client.post(
f"{base}/v1/images/generations",
headers={**headers, "Content-Type": "application/json"},
json=body,
)
_raise_api_error(resp, "RightAPI")
submitted = resp.json()
task_id = submitted.get("task_id") or ""
if task_id:
result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait)
else:
# 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理)
result = submitted
kind, payload = _rightapi_extract_image(result)
if kind == "b64" and payload:
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
if kind == "url" and payload:
dl = await client.get(payload, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
raise RuntimeError(f"RightAPI 任务完成但没有图片数据: {result}")
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
"""带重试的 RightAPI 入口:429/5xx/超时按递增间隔重试。
实测该中转对同 key 连续请求有分钟级冷却(成功一张后紧接着的请求会被网关秒拒 502),
60s → 120s → 240s 的退避基本能等到窗口放开。
"""
s = get_settings()
if not s.rightapi_api_key:
raise RuntimeError("未配置 RIGHTAPI_API_KEY.env")
model = model or s.rightapi_image_model
attempts = max(1, s.rightapi_max_retries)
last_exc: Exception | None = None
for i in range(attempts):
try:
return await _rightapi_request(s, prompt, ref_images, size, model)
except ApiError as exc:
last_exc = exc
if exc.status not in RETRYABLE_STATUS:
raise # 参数错误等不可重试,立即失败
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc # 网络抖动/超时可重试
if i == attempts - 1:
break
wait = s.rightapi_retry_wait * (2 ** i)
log.warning("RightAPI 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
await asyncio.sleep(wait)
raise last_exc # type: ignore[misc]
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
# ── 任务执行器 ────────────────────────────────────────────────────────────
def _order_refs(refs: list[str], type_id: str) -> list[str]:
"""参考图槽位选择 + 截断:material 偏好第 2 张,其余用第 1 张。"""
preferred = TYPE_REF_INDEX.get(type_id)
if preferred is not None and len(refs) > preferred:
refs = [refs[preferred]] + [r for i, r in enumerate(refs) if i != preferred]
return refs[:DEFAULT_REF_COUNT]
def _refs_for_job(images: list[dict], job: dict) -> list[str]:
"""无状态路径:按方案项选参考图。
优先 variant_name 精确匹配(「主图·粉色」用粉色那张 SKU 图);
匹配不到则回退 main 组第一张(再退到任意第一张)。
"""
variant = job.get("variant_name")
if variant:
matched = [i["url"] for i in images if i.get("variant_name") == variant]
if matched:
return matched[:DEFAULT_REF_COUNT]
mains = [i["url"] for i in images if i.get("group_key") == "main"]
others = [i["url"] for i in images if i.get("group_key") != "main"]
pool = mains or others or [i["url"] for i in images]
if not pool:
raise RuntimeError("任务没有参考图")
return _order_refs(pool, job.get("kind", ""))
# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
# rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
_GEN_LOCK = asyncio.Lock()
async def _store_image_bytes(task: Task, index: int, data: bytes) -> str:
"""生成图字节 → 存储落盘,返回可访问 URL。按 PNG 魔数定扩展名(部分中转不遵守 output_format)。"""
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
ext = ".png" if is_png else ".jpg"
ctype = "image/png" if is_png else "image/jpeg"
return await get_storage().save_bytes(data, f"suites/{task.id}/{index}{ext}", ctype)
async def run_suite(task: Task, on_image_ok: OnImageOk | None = None) -> None:
"""后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。
on_image_ok:每张成功落盘后的回调(API 层用于实时回写 product_assets/generated),
回调异常只记日志,不影响任务本身。
"""
settings = get_settings()
provider_name = task.provider or settings.image_provider
generator = GENERATORS.get(provider_name)
if generator is None:
task.status = TASK_FAILED
task.error = f"未知 provider: {provider_name}"
return
ctx = build_context(task.context or {}, fallback_name="")
model = task.model or {
"tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model)
jobs = [dict(j) for j in task.plan]
async with _GEN_LOCK:
task.status = TASK_RUNNING
ok, failed = 0, 0
failures: list[str] = []
for job in jobs:
type_id = job["kind"]
image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
task.images.append(image)
try:
# 提示词按模型家族分发:国产主体参考 / gpt edits 保真 / google 主体保持
prompt = build_prompt(
provider_name, model, type_id, ctx, task.style_set, task.lang,
extra=job, style_prompt=task.style_prompt, requirements=task.requirements,
)
refs = _refs_for_job(list(task.ref_images or []), job)
data = await generator(prompt, refs, size=size, model=model)
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
wm = task.watermark or {}
if wm.get("enabled"):
data = apply_watermark(data, wm)
image.url = await _store_image_bytes(task, len(task.images), data)
image.status = IMG_OK
ok += 1
if on_image_ok is not None and image.url:
try:
await on_image_ok(image)
except Exception: # noqa: BLE001
log.exception("套图 %s%d 张回写商品素材失败", task.id, len(task.images))
except Exception as exc: # noqa: BLE001
log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
image.status = IMG_FAILED # 默认 pending,失败显式置 failed
image.error = str(exc)[:500]
failures.append(f"{job.get('title') or type_name(type_id)}{str(exc)[:200]}")
failed += 1
task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED)
if failed:
uniq = list(dict.fromkeys(failures)) # 去重保序
detail = "".join(uniq[:6])
if len(uniq) > 6:
detail += f";…等共 {failed} 张失败"
if ok == 0:
task.error = f"全部生成失败。{detail}"
else:
task.error = f"部分生成失败({failed} 张)。{detail}"
+227
View File
@@ -0,0 +1,227 @@
"""出图方案规划器:DeepSeek 根据采集的商品信息生成套图方案。
方案每项 = 一类图(标题 + 说明 + 生图提示 + 张数 + 可选 SKU 绑定),
生成时按方案逐张出图;参考图可按 variant_name 精确绑定到对应 SKU 图。
"""
from __future__ import annotations
import json
import logging
import re
import httpx
from config import get_settings
log = logging.getLogger("suite.planner")
# 规划器可选用的图类型(与 prompt.py 的 builder 对应)
ALLOWED_KINDS = [
"white_bg", "key_features", "selling_pt", "material",
"lifestyle", "multi_scene", "ecommerce_detail",
"size_chart", "sku_collection", "custom",
]
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
## 输出硬性约束(违反即失败)
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数 8-15。
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
2. 场景图(kind=lifestyle):按核心使用场景出 2-4 张,每张聚焦一个场景。
3. 细节图(kind=material 或 custom):按关键细节/材质/结构出 2-3 张,每张聚焦一个卖点。
4. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
5. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
6. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
7. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
## 输出示例(紧凑单行)
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
def _system_prompt_with_requirements(requirements: str | None) -> str:
"""把生图要求作为最高优先级约束注入 system prompt(置于规划规则之前)。
不仅声明优先级,还明确要求把要求落地到每个方案项的 prompt_hint
避免模型只把要求当作背景信息而不影响输出。
"""
if not (requirements and requirements.strip()):
return SYSTEM_PROMPT
marker = "\n## 输出硬性约束"
idx = SYSTEM_PROMPT.find(marker)
if idx < 0:
return SYSTEM_PROMPT
req = requirements.strip()
block = (
"\n## 生图要求(最高优先级,硬性约束,覆盖下方所有规划规则与约束)\n"
+ req
+ "\n\n"
+ "规划方案时,必须把上述生图要求落地到每一项:\n"
+ "1. 每个方案项的 prompt_hint 必须融入上述要求的关键约束(如要求纯黑背景,则每个 prompt_hint 都要写明 black background);\n"
+ "2. title / detail 措辞不得与上述要求矛盾;\n"
+ "3. 任何规划规则与上述要求冲突时,一律以本生图要求为准。\n"
)
return SYSTEM_PROMPT[:idx] + block + SYSTEM_PROMPT[idx:]
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
"""清洗模型输出:kind 白名单、count 钳制、variant 必须真实存在。"""
items: list[dict] = []
for it in raw_items:
if not isinstance(it, dict):
continue
kind = str(it.get("kind") or "custom")
if kind not in ALLOWED_KINDS:
kind = "custom"
title = str(it.get("title") or "").strip()[:20]
if not title:
continue
try:
count = max(0, min(3, int(it.get("count", 1))))
except (TypeError, ValueError):
count = 1
variant = str(it.get("variant_name") or "").strip() or None
if variant and variant not in sku_variants:
variant = None # 幻觉规格:丢弃绑定,回退主图
items.append({
"kind": kind,
"title": title,
"detail": str(it.get("detail") or "").strip()[:80],
"prompt_hint": str(it.get("prompt_hint") or "").strip()[:300],
"count": count,
"variant_name": variant,
})
return items
def _repair_truncated(s: str) -> dict | None:
"""截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。
适用于「items 数组中途被 max_tokens 截断」的场景——截断点在完整对象边界,
此前的字符串必然已闭合,简单计数配平即可。
"""
for cut in (m.end() for m in reversed(list(re.finditer(r'\}', s)))):
cand = s[:cut].rstrip().rstrip(',')
opens: list[str] = []
for ch in cand:
if ch in '{[':
opens.append(ch)
elif ch == '}' and opens and opens[-1] == '{':
opens.pop()
elif ch == ']' and opens and opens[-1] == '[':
opens.pop()
suffix = ''.join('}' if o == '{' else ']' for o in reversed(opens))
try:
data = json.loads(cand + suffix)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
continue
return None
def _extract_json(text: str) -> dict:
"""从模型输出提取 JSON:剥离思考块/markdown 围栏,截断时尝试修复。"""
s = (text or '').strip()
# 剥离思考块(思考型模型会把推理过程放进 <think>
s = re.sub(r'<think>.*?</think>', '', s, flags=re.S).strip()
# 剥离 markdown 代码围栏
m = re.search(r'```(?:json)?\s*(.*?)```', s, flags=re.S)
if m:
s = m.group(1).strip()
try:
return json.loads(s)
except json.JSONDecodeError:
pass
start = s.find('{')
if start >= 0:
repaired = _repair_truncated(s[start:])
if repaired is not None:
log.warning("规划器输出疑似被截断,已自动截断修复(可能丢失末尾部分方案项)")
return repaired
raise ValueError("模型输出无法解析为 JSON")
async def generate_plan(
product_info: dict,
sku_variants: list[str],
image_stats: dict,
platform: str,
requirements: str | None = None,
) -> dict:
"""调用 DeepSeek 生成方案。返回 {summary, items}。
requirements:生图要求,最高优先级注入 system prompt,规划方案必须遵循。
"""
s = get_settings()
if not s.deepseek_api_key:
raise RuntimeError("未配置 DEEPSEEK_API_KEY.env")
user_payload: dict = {
"商品信息": product_info, # {title, desc, params:[{key,value}], sellingPoints, price}
"SKU规格": sku_variants, # 带图的 SKU 规格名(variant_name 只能从中选)
"图片统计": image_stats, # {main: n, sku: n, detail: n}
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
}
# 生图要求同时在 user 侧强调(与 system prompt 双重约束),确保模型真正遵循
if requirements and requirements.strip():
user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip()
user_content = json.dumps(user_payload, ensure_ascii=False)
async with httpx.AsyncClient(timeout=90, verify=False) as client:
resp = await client.post(
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"},
json={
"model": s.deepseek_model,
"messages": [
{"role": "system", "content": _system_prompt_with_requirements(requirements)},
{"role": "user", "content": user_content},
],
"response_format": {"type": "json_object"},
"temperature": 0.3,
"max_tokens": 8000,
},
)
resp.raise_for_status()
body = resp.json()
message = body["choices"][0]["message"]
finish_reason = body["choices"][0].get("finish_reason", "")
usage = body.get("usage") or {}
log.info(
"规划器 token 用量: prompt=%s completion=%s finish=%s",
usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"), finish_reason,
)
content = message.get("content") or ""
# 思考型输出:content 为空时从 reasoning_content 里捞
if not content.strip() and message.get("reasoning_content"):
content = message["reasoning_content"]
try:
data = _extract_json(content)
except ValueError as exc:
log.error(
"规划器输出解析失败 finish_reason=%s content[:200]=%s",
finish_reason, content[:200],
)
raise RuntimeError("规划器输出解析失败") from exc
items = _normalize_items(data.get("items") or [], sku_variants)
if not items:
raise RuntimeError("规划器未返回有效方案项")
# 总量保护:超过 18 张时按比例截断
total = sum(i["count"] for i in items)
while total > 18 and items:
last = items[-1]
if last["count"] > 1:
last["count"] -= 1
else:
items.pop()
total = sum(i["count"] for i in items)
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
+45
View File
@@ -0,0 +1,45 @@
"""套图提示词引擎:按模型家族分发,各家族独立封装。
不同家族的生图语义差异极大,共用一套提示词会导致语义错配
gpt-image-2 按文字重造商品即由此而来),故按家族各自成册:
alibaba 通义 wan*/qwen*DashScope)—— 主体参考语义
doubao 豆包 Seedream(火山方舟)—— 主体参考语义,与通义共用装配
gpt gpt-image-2 / gpt-image-2-vipRightAPI)—— /v1/images/edits 编辑语义
google nano-banana 系列(RightAPI)—— 原生主体保持语义
路由规则:provider 为主;rightapi 内再按模型名细分 gpt / google。
"""
from __future__ import annotations
from . import alibaba, doubao, google, gpt
from .common import build_context, type_name
_MODULE_BY_FAMILY = {
"alibaba": alibaba,
"doubao": doubao,
"gpt": gpt,
"google": google,
}
def prompt_family(provider: str, model: str | None) -> str:
"""(provider, model) → 提示词家族名。"""
if provider == "rightapi":
if (model or "").lower().startswith("nano-banana"):
return "google"
return "gpt" # gpt-image-* 及未知中转模型默认按 edits 语义处理
if provider == "tongyi":
return "alibaba"
return "doubao" # doubao 及默认 provider
def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int,
lang: str, extra: dict | None = None, style_prompt: str | None = None,
requirements: str | None = None) -> str:
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
return module.build_prompt(
type_id, ctx, style_set, lang,
extra=extra, style_prompt=style_prompt, requirements=requirements,
)
+169
View File
@@ -0,0 +1,169 @@
"""阿里通义(wan* 万相 / qwen* 千问)提示词:国产"主体参考"语义。
生图 API 把参考图当商品锚(subject reference)、prompt 当场景描述,
风格词/文字商品描述不会反噬商品本体,负面清单也可以安全写入 prompt。
豆包(doubao.py)与此语义一致,直接复用本模块装配。
"""
from __future__ import annotations
from .common import (
STYLE_SETS, TEXT_RENDER, requirements_block, resolve_style, selling_point_lines,
)
# ── 公共组件(主体参考语义专用)────────────────────────────────────────────
QUALITY = (
"Shot on Sony A7R V with 85mm lens at f/2.0, ultra-detailed, photorealistic, "
"8K commercial image quality, professional retouching."
)
PRODUCT_REF_LOCK = (
"CRITICAL: The product must look EXACTLY the same as in the reference image — "
"identical silhouette, proportions, colors, print pattern, stitching and every design detail. "
"Only the background, camera angle, lighting and styling may change. "
"Do not redesign, add or remove any element of the product."
)
DEFAULT_NEGATIVE_INTENT = (
"no AI-generated look, no CGI quality, no plastic appearance, no watermark, "
"no distorted text, no deformed product, no extra limbs, no blurry areas"
)
# ── 各图类型 Prompt ───────────────────────────────────────────────────────
def _prompt_white_bg(ctx: dict, style: dict, lang: str) -> str:
return (
f"E-commerce main product image on pure white background (RGB 255,255,255), "
f"product \"{ctx['title']}\" centered and filling about 85% of the frame, "
f"front view, even shadowless studio lighting with a faint natural contact shadow, "
f"{style['tone']}. No text, no props, no background elements. {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_key_features(ctx: dict, style: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
return (
f"E-commerce key-features infographic for product \"{ctx['title']}\", square layout: "
f"product on the left two-thirds ({style['bg']}), right column lists 3 feature callouts "
f"with minimal line icons, thin leader lines pointing to product details. "
f"Feature callouts: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_selling_pt(ctx: dict, style: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
return (
f"Single-selling-point e-commerce poster for product \"{ctx['title']}\": "
f"hero product close-up at dynamic angle ({style['bg']}), one large bold headline "
f"about \"{sp}\", generous negative space, one small magnified detail circle "
f"highlighting material or craft. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
return (
f"Macro material close-up of product \"{ctx['title']}\": extreme detail shot revealing "
f"fabric weave / surface texture / stitching / finish, shallow depth of field, "
f"raking light across the surface, {style['tone']}. Small caption label in corner. "
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_lifestyle(ctx: dict, style: dict, lang: str) -> str:
bg = f" ({style['bg']})" if style.get("bg") else ""
return (
f"Lifestyle in-context scene for product \"{ctx['title']}\": the product is naturally "
f"used / placed in a real environment{bg}, realistic human-scale surroundings, "
f"soft daylight, authentic candid mood, product remains the clear visual focus. "
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_multi_scene(ctx: dict, style: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang)
return (
f"Triptych multi-scene e-commerce image for product \"{ctx['title']}\": three vertical panels "
f"separated by thin gutters, each panel shows the SAME product in a different usage scene "
f"(e.g. home interior / outdoor street / office desk), consistent color grading across panels. "
f"Panel captions: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
params = ctx["params_line"]
return (
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
f"top half is a hero banner with the product at a 3/4 angle ({style['bg']}); "
f"bottom half is a clean spec card listing 3 feature rows with line icons"
+ (f" (specs: {params})" if params else "")
+ f" and one highlighted row: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
dims = ctx["params_line"]
return (
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
f"measurement values rendered next to each line"
+ (f" (known specs: {dims})" if dims else "")
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_sku_collection(ctx: dict, style: dict, lang: str) -> str:
return (
f"Colorway collection image for product \"{ctx['title']}\": the SAME product in all its color/variant "
f"options arranged in a neat equal grid (2-4 items per row), each colorway with a small label chip below it, "
f"consistent lighting and scale across all items, clean e-commerce presentation. "
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
hint = (extra.get("prompt_hint") or "").strip()
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
bg = f" {style['bg']} as environment." if style.get("bg") else ""
composed = (
f"E-commerce marketing image for product \"{ctx['title']}\""
+ (f"{purpose}" if purpose else "")
+ (f": {detail}" if detail else "")
+ "."
)
if hint:
composed += f" Composition: {hint}."
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
_PROMPT_BUILDERS = {
"white_bg": _prompt_white_bg,
"key_features": _prompt_key_features,
"selling_pt": _prompt_selling_pt,
"material": _prompt_material,
"lifestyle": _prompt_lifestyle,
"multi_scene": _prompt_multi_scene,
"ecommerce_detail": _prompt_ecommerce_detail,
"size_chart": _prompt_size_chart,
"sku_collection": _prompt_sku_collection,
}
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:
"""构造指定图类型的完整生图 prompt(主体参考语义)。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
预设类型也会把 prompt_hint 作为构图补充注入。
style_prompt: 用户改写的风格提示词,覆盖 style_set 内置模板(tone/bg 整体替换)。
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
声明覆盖一切冲突指令,用户可在此输入强制要求。
"""
style = resolve_style(style_set, style_prompt)
extra = extra or {}
if type_id == "custom":
prompt = _prompt_custom(ctx, style, lang, extra)
else:
builder = _PROMPT_BUILDERS.get(type_id)
if builder is None:
raise ValueError(f"未知图类型: {type_id}")
prompt = builder(ctx, style, lang)
hint = (extra.get("prompt_hint") or "").strip()
if hint:
prompt = prompt.rstrip(".") + f". Additional composition guidance: {hint}."
req = requirements_block(requirements)
if req:
prompt = f"{req} {prompt}"
return prompt + ". " + DEFAULT_NEGATIVE_INTENT
+154
View File
@@ -0,0 +1,154 @@
"""提示词公共层:与模型家族无关的商品上下文、风格模板、图类型名与文案组件。
各家族模块(alibaba / doubao / gpt / google)只负责"如何对模型说话"
商品信息提炼与风格体系统一在这里维护,避免多处漂移。
"""
from __future__ import annotations
import re
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应;提示词可被用户在插件里改写覆盖)───
STYLE_SETS: dict[int, dict] = {
1: {
"name": "北欧极简",
"tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
"bg": "",
},
2: {
"name": "清新明亮",
"tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
"bg": "",
},
3: {
"name": "高级感深色",
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
"bg": "",
},
4: {
"name": "暖调生活",
"tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
"bg": "",
},
5: {
"name": "纯净棚拍",
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
"bg": "",
},
}
# ── 图类型中文名(导出文件名用)───────────────────────────────────────────
TYPE_NAMES_ZH: dict[str, str] = {
"white_bg": "白底主图",
"key_features": "核心卖点图",
"selling_pt": "卖点图",
"material": "材质图",
"lifestyle": "场景展示图",
"multi_scene": "多场景拼图",
"ecommerce_detail": "电商详情图",
"size_chart": "尺寸标注图",
"sku_collection": "SKU合集图",
"custom": "创意图",
}
# ── 图内营销文案渲染规范(各家族共用;语言由平台决定)──────────────────────
TEXT_RENDER = {
"zh": (
"Render concise Chinese marketing text inside the image: main headline max 8 Chinese characters, "
"sub-lines max 12 characters each, font is modern clean sans-serif (Source Han Sans style), "
"high legibility, tasteful typography layout, colors harmonized with the composition. "
"No spelling errors, no garbled characters."
),
"en": (
"Render concise English marketing text inside the image: headline max 5 words, "
"sub-lines max 8 words each, Helvetica Neue style sans-serif, high legibility, "
"tasteful typography layout, colors harmonized with the composition. No spelling errors."
),
"ru": (
"Render concise Russian marketing text inside the image: headline max 4 words, "
"sub-lines max 6 words each, modern clean sans-serif (Inter / PT Sans style), "
"proper Cyrillic typography, high legibility, tasteful layout, colors harmonized with the composition. "
"No spelling errors, no mixed latin/cyrillic gibberish."
),
}
def resolve_style(style_set: int, style_prompt: str | None = None) -> dict:
"""用户改写的风格提示词整体覆盖内置模板(tone/bg 整体替换)。"""
if style_prompt and style_prompt.strip():
return {"name": "custom", "tone": style_prompt.strip(), "bg": ""}
return STYLE_SETS.get(style_set, STYLE_SETS[1])
def requirements_block(requirements: str | None) -> str:
"""用户强制要求块:最高优先级、置于提示词最前、覆盖冲突指令(原文保留不翻译)。"""
if requirements and requirements.strip():
return (
"STRICT REQUIREMENTS (highest priority, must be followed exactly, "
"override any conflicting instruction): "
+ requirements.strip().rstrip(".")
+ "."
)
return ""
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
def _shorten(text: str, n: int) -> str:
text = re.sub(r"\s+", " ", (text or "")).strip()
return text[:n]
def _clean_title(title: str) -> str:
"""去掉常见堆砌词,让标题更可读。"""
t = _shorten(title, 60)
return re.sub(r"[【【】】\\[\\]|/]", " ", t).strip()
def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -> dict:
"""从采集数据提炼生图上下文:标题、描述行、卖点列表、参数行。
raw: {title, desc, price, params: [{key, value}], sellingPoints}
"""
title = _clean_title(raw.get("title") or fallback_name or "product")
desc = _shorten(raw.get("desc") or fallback_desc or "", 200)
# 卖点:优先显式卖点文本;否则从参数表里挑短而有信息量的键值对
selling_points: list[dict] = []
sp_text = raw.get("sellingPoints") or ""
if sp_text:
for chunk in re.split(r"[;\n·]+|(?<!\d)\.(?!\d)", sp_text):
c = _shorten(chunk, 20)
if c and len(selling_points) < 5:
selling_points.append({"zh": c, "en": c})
if not selling_points:
for p in (raw.get("params") or [])[:12]:
k, v = _shorten(p.get("key", ""), 10), _shorten(str(p.get("value", "")), 16)
if k and v and k.lower() not in {"货号", "sku", "isbn", "上架时间"}:
selling_points.append({"zh": f"{k} {v}", "en": f"{k} {v}"})
if len(selling_points) >= 5:
break
params_line = "; ".join(
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
)
return {
"title": title,
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
"desc": desc,
"selling_points": selling_points[:3],
"params_line": params_line,
"price": raw.get("price") or "",
}
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
"""卖点列表 → 单行文案(图内 callout/标题用),无卖点返回空串。"""
sps = ctx["selling_points"][:max_n]
if not sps:
return ""
key = "zh" if lang == "zh" else "en"
return "; ".join(s[key] for s in sps if s.get(key))
def type_name(type_id: str) -> str:
return TYPE_NAMES_ZH.get(type_id, type_id)
+1
View File
@@ -55,6 +55,7 @@ SYSTEM_PROMPT = """你是一名熟悉俄罗斯消费者表达习惯、Ozon 商
【标签】
1. 输出10~15个标签;每个俄文标签必须是一个独立单词,不是短语,不带 #,不含标点,不把两个词用空格连接。
2. 标签中绝对禁止出现任何特殊符号:禁止 - 连字符/中划线、_ 下划线、/ 斜杠、. 句点、, 逗号等一切非字母字符(俄文/英文字母之外的字符一律不允许);需要组合概念时用去掉连字符的合成词写法(例如"новогоднийподарок"式复合词或改用两个独立标签)。
2. 标签优先覆盖品类、造型、材质、功能、风格、摆放场景等高相关搜索概念,避免同词不同变格反复出现。
3. 中文标签也尽量为一个词;tags_ru 与 tags_zh 必须逐项语义对应。
+9
View File
@@ -0,0 +1,9 @@
"""豆包(火山方舟 Seedream)提示词。
豆包与通义同为国产"主体参考"生图模型:参考图即商品锚、prompt 为场景描述,
提示词语义一致,直接复用阿里系装配;差异(去 AI 味后缀)在 generator 层追加。
独立成文件便于后续按豆包特性分化。
"""
from __future__ import annotations
from .alibaba import build_prompt as build_prompt # noqa: F401 主体参考语义与通义共用
+163
View File
@@ -0,0 +1,163 @@
"""Google 图像模型(nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-pro)提示词。
语义:Gemini 图像编辑 —— 原生主体保持能力强,输入图即"主体 + 底图"
对自然语言指令遵循好。不套用 GPT 的编辑契约(冗长的拒绝条款反而稀释指令),
也不用负面清单(无 negative_prompt 参数)。要点:
- 开头一句话钉死"主体 = 第一张图里的商品,逐像素保持"
- 指令自然语言描述目标画面(场景/排版/文案),不重述商品外观;
- 标题/参数仅作识别背景并声明以图为准。
"""
from __future__ import annotations
from .common import TEXT_RENDER, requirements_block, resolve_style, selling_point_lines
_SUBJECT_LOCK = (
"SUBJECT LOCK (highest priority): the product in the first image is the subject. "
"Keep it exactly as photographed — same shape, proportions, colors, print/pattern, "
"logo, label and every detail — and place that very product into the result. "
"A second image, when present, is another view of the same product for reference only."
)
_QUALITY = (
"OUTPUT: photorealistic commercial e-commerce photography, ultra-detailed, "
"natural light and shadow, professional retouching."
)
_REMINDER = (
"Reminder: keep the product exactly as in the first image; change only its surroundings, "
"composition, lighting and overlay graphics."
)
def _anchor(ctx: dict) -> str:
"""商品文字锚定:仅供识别,明确以图为准(同 gpt 模块,避免文字反噬商品)。"""
line = f"Context (identification only): the product is \"{ctx['title']}\""
if ctx.get("params_line"):
line += f" ({ctx['params_line']})"
return line + ". The image, not this text, defines the product's appearance."
# ── 各图类型指令(自然语言编辑口吻)────────────────────────────────────────
def _task_white_bg(ctx: dict, lang: str) -> str:
return (
"Replace the background of this product photo with seamless pure white (RGB 255,255,255): "
"product centered in front view filling about 85% of the frame, even studio lighting with only "
"a faint natural contact shadow. No props, no added text, no background elements."
)
def _task_key_features(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
return (
"Create a square key-features infographic: the unchanged product on the left two-thirds, "
"a clean right-hand panel with 3 feature callouts using minimal line icons and thin leader "
f"lines pointing at the product. Callout copy: {sp}."
)
def _task_selling_pt(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
return (
"Turn the photo into a single-selling-point poster: hero close-up of the unchanged product at "
f"a dynamic angle, one large bold headline about \"{sp}\", generous negative space, and a small "
"magnified circle zooming into an existing detail of the product."
)
def _task_material(ctx: dict, lang: str) -> str:
return (
"Create an extreme macro close-up of an existing area of the product's surface, showing its "
"true fabric weave / texture / stitching exactly as in the photo; shallow depth of field, "
"raking light, small caption in a corner."
)
def _task_lifestyle(ctx: dict, lang: str) -> str:
return (
"Place the unchanged product into a realistic everyday scene where it would naturally be used: "
"human-scale surroundings, soft daylight, authentic candid mood, the product as the clear visual focus."
)
def _task_multi_scene(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang)
task = (
"Build a triptych of three vertical panels separated by thin gutters: each panel shows an "
"identical copy of the product in a different usage scene (home interior / outdoor street / "
"office desk), with consistent color grading across panels."
)
if sp:
task += f" Panel captions: {sp}."
return task
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
params = ctx["params_line"]
return (
"Compose a square detail-page hero section: top half a hero banner with the unchanged product "
"at a 3/4 angle; bottom half a clean spec card with 3 feature rows and line icons"
+ (f" (specs: {params})" if params else "")
+ f", one highlighted row: {sp}."
)
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = ctx["params_line"]
return (
"Create a size chart: the unchanged product in clean front and side views on a light background, "
"thin measurement annotation lines (arrows) marking length, width and height with values beside "
"each line"
+ (f" (known specs: {dims})" if dims else "")
+ ", small caption row, precise technical-drawing aesthetic."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
# 不展开"全部配色":会凭空造出新商品;只排列同一件的多个副本
return (
"Arrange several identical copies of the product in a neat equal grid (2-4 per row) with a small "
"label chip below each copy; identical lighting and scale across copies. Every copy shows this "
"exact product — do not invent other colorways or variants."
)
_TASK_BUILDERS = {
"white_bg": (_task_white_bg, False),
"key_features": (_task_key_features, True),
"selling_pt": (_task_selling_pt, True),
"material": (_task_material, True),
"lifestyle": (_task_lifestyle, True),
"multi_scene": (_task_multi_scene, True),
"ecommerce_detail": (_task_ecommerce_detail, True),
"size_chart": (_task_size_chart, True),
"sku_collection": (_task_sku_collection, True),
}
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:
"""构造指定图类型的 prompt:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。"""
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
if type_id == "custom":
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
task = "Create an e-commerce marketing image featuring the product from the first image"
task += f"{purpose}" if purpose else ""
task += f": {detail}" if detail else ""
task += "."
wants_text = True
else:
entry = _TASK_BUILDERS.get(type_id)
if entry is None:
raise ValueError(f"未知图类型: {type_id}")
builder, wants_text = entry
task = builder(ctx, lang)
if hint:
task += f" Composition guidance: {hint}."
parts = [p for p in (requirements_block(requirements),) if p]
parts.append(task)
parts.append(_SUBJECT_LOCK)
parts.append(_anchor(ctx))
parts.append(f"Scene style (scene and background only, never the product): {style['tone']}.")
if wants_text:
parts.append(f"Text overlay (a graphic layer, never printed on the product): {TEXT_RENDER[lang]}")
parts.append(_QUALITY)
parts.append(_REMINDER)
return "\n\n".join(parts)
+195
View File
@@ -0,0 +1,195 @@
"""GPT 图像模型(gpt-image-2 / gpt-image-2-vipRightAPI 中转)提示词。
语义:/v1/images/edits —— 输入图是"被编辑的照片"prompt 是编辑指令;
与通义/豆包的"主体参考"语义完全不同:参考图不是商品锚,模型会按文字指令
重新渲染整张图。此前与国产模型共用场景提示词,再用文字锚定商品并要求输出
"匹配商品描述",导致模型把商品改造成营销关键词描述的样子(必现商品被改)。
本模块写法原则:
1. 商品只由 Image 1 定义;标题/参数仅作识别背景并声明"以图为准"
绝不要求输出匹配文字描述(那等于授权模型改商品);
2. 指令只说"改什么"(背景/场景/排版/文案),不描述商品外观;
3. 分节精简、首尾重申保真;不用负面清单(gpt 无 negative_prompt 参数,
罗列畸形反而往上下文植入概念);
4. sku 合集 / 多拼图明确"复制同一件商品,禁止发明新配色或变体"
"""
from __future__ import annotations
from .common import TEXT_RENDER, requirements_block, resolve_style, selling_point_lines
# 保真锁:商品由 Image 1 唯一定义,其余指令一律不得触碰商品本体
_PRESERVE = (
"PRESERVE (absolute, overrides every other instruction below): the product shown in Image 1. "
"Reuse the photographed product exactly as it is — identical shape, silhouette, proportions, "
"colors, print/pattern, logo and label text, materials, stitching and surface details. "
"Do not redesign, restyle, recolor, re-pattern, tidy up or substitute the product, "
"and do not let any style or text instruction below alter it. Image 2 is a secondary "
"view of the same product for reference only."
)
_STYLE = (
"SCENE STYLE (applies to background, scene, props and lighting only — never to the product): "
)
_QUALITY = (
"OUTPUT: photorealistic commercial e-commerce photography, ultra-detailed, "
"natural light and shadow, professional retouching."
)
_REMINDER = (
"FINAL CHECK: the product itself must remain exactly as photographed in Image 1 — "
"only its surroundings, composition, lighting and overlay graphics may differ."
)
# 图内文案:明确是"排版图层",不落在商品本体上
_TEXT_SCOPE = (
"TEXT OVERLAY (a graphic layer on the composition, never printed on the product): "
)
def _anchor(ctx: dict) -> str:
"""商品文字锚定:仅供识别,明确声明以图为准。
只放标题 + 参数、不放营销描述——描述里的卖点词("卡通""加固""防水"等)
在 edits 语义下会被执行到商品上;官逆通道(-vip)参考图被弱化时,
文字锚定用于帮模型认出"是哪件商品",而不是"长什么样"
"""
line = f"CONTEXT (identification only): the product is \"{ctx['title']}\""
if ctx.get("params_line"):
line += f" ({ctx['params_line']})"
return (
line
+ ". Image 1 — not this text — defines the product's appearance; "
"if they ever conflict, follow Image 1."
)
# ── 各图类型的编辑指令(只描述改动,不描述商品)────────────────────────────
def _task_white_bg(ctx: dict, lang: str) -> str:
return (
"TASK: Clean up this product photo for a marketplace listing. Replace the entire "
"background with seamless pure white (RGB 255,255,255); recompose with the product "
"centered in front view filling about 85% of the frame; keep only a faint natural "
"contact shadow. No props, no text, no background elements."
)
def _task_key_features(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
return (
"TASK: Feature infographic on a square canvas. Keep the product unchanged on the left "
"two-thirds; build the right third as a clean info panel listing 3 feature callouts with "
f"minimal line icons and thin leader lines pointing at parts of the product. Callout copy: {sp}."
)
def _task_selling_pt(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
return (
"TASK: Single-selling-point poster. Hero close-up of the unchanged product at a dynamic "
f"angle, generous negative space, one large bold headline about \"{sp}\", plus one small "
"magnified circle zooming into an existing detail of the product (zoom only — do not "
"invent details that are not in the photo)."
)
def _task_material(ctx: dict, lang: str) -> str:
return (
"TASK: Material close-up. Zoom tightly into an existing area of the product's surface and "
"show its true texture — fabric weave, surface finish, stitching — exactly as it appears in "
"Image 1; shallow depth of field, raking light across the surface; small caption label in a corner."
)
def _task_lifestyle(ctx: dict, lang: str) -> str:
return (
"TASK: Lifestyle scene. Place the unchanged product into a realistic everyday environment "
"where it would naturally be used: human-scale surroundings, soft daylight, authentic candid "
"mood, matched shadows and color temperature, the product remaining the clear visual focus."
)
def _task_multi_scene(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang)
task = (
"TASK: Triptych showcase. Build three vertical panels separated by thin gutters; every panel "
"contains an IDENTICAL copy of the product from Image 1 (do not re-render it differently per "
"panel) placed in a different usage scene (e.g. home interior / outdoor street / office desk), "
"with consistent color grading across panels."
)
if sp:
task += f" Panel captions: {sp}."
return task
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
params = ctx["params_line"]
return (
"TASK: Detail-page hero section on a square canvas. Top half: hero banner with the unchanged "
"product at a 3/4 angle. Bottom half: clean spec card with 3 feature rows and line icons"
+ (f" (specs: {params})" if params else "")
+ f", one highlighted row: {sp}."
)
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = ctx["params_line"]
return (
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
"background; overlay thin technical annotation lines (arrows) marking length, width and height "
"with measurement values rendered beside each line"
+ (f" (known specs: {dims})" if dims else "")
+ "; precise technical-drawing aesthetic, small caption row."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
# 关键差异:不允许像国产模型那样展开"全部配色"——edits 语义下那会凭空造出新商品
return (
"TASK: Product lineup. Arrange several IDENTICAL copies of the product from Image 1 in a neat "
"equal grid (2-4 per row) with a small label chip below each copy; identical lighting and scale "
"across copies. Every copy must show this exact product — do NOT invent other colorways, "
"variants or versions."
)
_TASK_BUILDERS = {
"white_bg": (_task_white_bg, False),
"key_features": (_task_key_features, True),
"selling_pt": (_task_selling_pt, True),
"material": (_task_material, True),
"lifestyle": (_task_lifestyle, True),
"multi_scene": (_task_multi_scene, True),
"ecommerce_detail": (_task_ecommerce_detail, True),
"size_chart": (_task_size_chart, True),
"sku_collection": (_task_sku_collection, True),
}
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:
"""构造指定图类型的 edits 语义 prompt:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。"""
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
if type_id == "custom":
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
task = "TASK: Create an e-commerce marketing image featuring the product from Image 1"
task += f"{purpose}" if purpose else ""
task += f": {detail}" if detail else ""
task += "."
wants_text = True
else:
entry = _TASK_BUILDERS.get(type_id)
if entry is None:
raise ValueError(f"未知图类型: {type_id}")
builder, wants_text = entry
task = builder(ctx, lang)
if hint:
task += f" Composition guidance: {hint}."
parts = [p for p in (requirements_block(requirements),) if p]
parts.append(task)
parts.append(_PRESERVE)
parts.append(_anchor(ctx))
parts.append(f"{_STYLE}{style['tone']}.")
if wants_text:
parts.append(f"{_TEXT_SCOPE}{TEXT_RENDER[lang]}")
parts.append(_QUALITY)
parts.append(_REMINDER)
return "\n\n".join(parts)
+12
View File
@@ -103,3 +103,15 @@ def get_storage():
if settings.use_qiniu:
return QiniuStorage()
return LocalStorage()
def local_path(stored_url_or_key: str) -> Path | None:
"""stored_urlhttp…/media/xxx)或 media key → 本地文件路径。
仅本地存储模式下可命中;七牛 URL 不含 /media/,返回 None 由调用方回退远程下载。
"""
s = stored_url_or_key or ""
if "/media/" in s:
s = s.split("/media/", 1)[1]
p = _LOCAL_ROOT / s
return p if p.is_file() else None
+70
View File
@@ -0,0 +1,70 @@
"""内存任务注册表:套图生成任务的生命周期与进程一致(重启即新会话)。
轮询/导出只服务「当前会话正在跟踪的任务」——前端没有历史记录功能,
任务状态无需跨进程持久化;重启后轮询自然 404,前端提示任务已中断。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
# 任务状态
TASK_PENDING = "pending"
TASK_RUNNING = "running"
TASK_DONE = "done"
TASK_PARTIAL = "partial"
TASK_FAILED = "failed"
# 任务内单张图状态
IMG_PENDING = "pending" # 生成中(前端据此隐藏占位格,只渲染 ok/failed 终态)
IMG_OK = "ok"
IMG_FAILED = "failed"
@dataclass
class TaskImage:
"""任务里单张生成图:完成一张追加一条(前端进度 x/y 依赖此语义)。"""
type_id: str
name: str
status: str = IMG_PENDING # 循环里先建后跑,成功改 ok、失败显式改 failed
url: str = ""
error: str | None = None
@dataclass
class Task:
"""一次套图生成任务:轮询可见字段 + 仅供 run_suite 消费的执行参数。"""
id: str
status: str = TASK_PENDING
platform: str = "cn"
lang: str = "zh"
ratio: str = "1:1"
style_set: int = 1
style_prompt: str | None = None
requirements: str | None = None
provider: str = ""
model: str | None = None
total: int = 0 # 计划总张数(进度分母)
images: list[TaskImage] = field(default_factory=list)
error: str | None = None
# ── 执行参数(不进轮询响应)──
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
watermark: dict | None = None # 水印选项(落盘前服务端后处理)
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
_TASKS: dict[str, Task] = {}
def create_task(**kwargs) -> Task:
task = Task(id=uuid.uuid4().hex, **kwargs)
_TASKS[task.id] = task
return task
def get_task(task_id: str) -> Task | None:
return _TASKS.get(task_id)
+122
View File
@@ -0,0 +1,122 @@
"""生成图水印:AI 出图返回后、落盘前的后处理合成(不经过生图模型)。
样式复刻 ozonSeller「图表处理」的默认水印:
- 图片水印:徽章图中心裁方 → 圆形遮罩 → 宽度为图宽 15%,右下角,边距约 1% 图宽;
- 文字水印:字号为图宽 6%(下限 12px),白色填充 + 黑色描边(alpha 0.55
描边宽 fontSize/8),加粗无衬线。
容错原则:字体/资产缺失或合成异常时 log 警告并返回原图,绝不阻断生图。
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from config import get_settings
log = logging.getLogger("suite.watermark")
# 尺寸比例(与 ozonSeller app.js 常量一致)
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
TEXT_SCALE = 0.06 # 文字字号 / 图宽
TEXT_MIN_SIZE = 12
STROKE_ALPHA = 0.55
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
# CJK/西文字体回退链(macOS 本地服务);命中后模块级缓存
_FONT_CANDIDATES = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
_font_path: str | None = None
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
global _font_path
if _font_path is None:
_font_path = next((p for p in _FONT_CANDIDATES if Path(p).is_file()), "")
if _font_path:
return ImageFont.truetype(_font_path, size)
log.warning("未找到系统字体(%s),文字水印退化为 Pillow 默认字体,中文可能乱码", _FONT_CANDIDATES)
return ImageFont.load_default(size) if size >= 10 else ImageFont.load_default()
def _circular_badge(size: int) -> Image.Image | None:
"""徽章资产 → 指定直径的圆形 RGBA 贴片;资产缺失返回 None。"""
path = get_settings().watermark_image_path
try:
badge = Image.open(path).convert("RGBA")
except Exception as exc: # noqa: BLE001
log.warning("水印图片加载失败(%s),跳过图片水印: %s", path, exc)
return None
side = min(badge.size) # 中心裁方
left, top = (badge.width - side) // 2, (badge.height - side) // 2
square = badge.crop((left, top, left + side, top + side)).resize((size, size))
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
square.putalpha(mask)
return square
def _apply_image_watermark(canvas: Image.Image, opacity: float) -> None:
size = max(24, round(canvas.width * BADGE_SCALE))
badge = _circular_badge(size)
if badge is None:
return
badge.putalpha(badge.getchannel("A").point(lambda a: round(a * opacity)))
margin = max(10, round(canvas.width * BADGE_MARGIN))
canvas.alpha_composite(badge, (canvas.width - size - margin, canvas.height - size - margin))
def _apply_text_watermark(canvas: Image.Image, text: str, opacity: float) -> None:
text = (text or "").strip()
if not text:
return
font_size = max(TEXT_MIN_SIZE, round(canvas.width * TEXT_SCALE))
font = _load_font(font_size)
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
if tw >= canvas.width: # 文案比图还宽:按比例缩字号重排一次
font_size = max(TEXT_MIN_SIZE, round(font_size * canvas.width / tw * 0.94))
font = _load_font(font_size)
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
margin = max(10, round(canvas.width * BADGE_MARGIN))
x = canvas.width - tw - margin - bbox[0]
y = canvas.height - th - margin - bbox[1]
stroke = (0, 0, 0, round(255 * STROKE_ALPHA))
fill = (255, 255, 255, 255)
draw.text((x, y), text, font=font, fill=fill, stroke_width=max(1, round(font_size * STROKE_RATIO)),
stroke_fill=stroke)
layer.putalpha(layer.getchannel("A").point(lambda a: round(a * opacity)))
canvas.alpha_composite(layer)
def apply_watermark(data: bytes, opts: dict) -> bytes:
"""给图片字节加水印,返回同格式字节;opts: {type, text, opacity(0-100)}。"""
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
fmt = "PNG" if is_png else "JPEG"
try:
img = Image.open(io.BytesIO(data))
canvas = img.convert("RGBA")
opacity = min(100, max(1, int(opts.get("opacity") or 30))) / 100
if opts.get("type") == "text":
_apply_text_watermark(canvas, opts.get("text") or "", opacity)
else:
_apply_image_watermark(canvas, opacity)
out = io.BytesIO()
if fmt == "PNG":
canvas.save(out, format="PNG")
else:
canvas.convert("RGB").save(out, format="JPEG", quality=95)
return out.getvalue()
except Exception: # noqa: BLE001
log.exception("水印合成失败,返回原图")
return data
+3
View File
@@ -50,6 +50,9 @@ export const getPageInfo = (path: string): { title: string; subtitle: string } =
const first = routeMenuConfig[0];
return { title: first.title, subtitle: first.subtitle };
}
if (path.startsWith('/trial/')) {
return { title: '商品试算', subtitle: '计价、俄文文案、AI 生图、入库与导出' };
}
if (path.startsWith('/product/')) {
return { title: '商品编辑', subtitle: '编辑商品信息、计价、文案与图片' };
}
@@ -81,7 +81,7 @@ export default function CollectionPage() {
title: '商品名',
dataIndex: 'name',
render: (v, r) => (
<a onClick={() => navigate(`/product/${r.id}`)}>{v || '(未命名)'}</a>
<a onClick={() => navigate(`/trial/${r.id}`)}>{v || '(未命名)'}</a>
),
},
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
@@ -112,9 +112,12 @@ export default function CollectionPage() {
},
{
title: '操作',
width: 210,
width: 260,
render: (_, r) => (
<Space>
<Button size="small" type="primary" ghost onClick={() => navigate(`/trial/${r.id}`)}>
</Button>
<Button size="small" onClick={() => navigate(`/product/${r.id}`)}>
</Button>
+60 -24
View File
@@ -1,18 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Card, Col, Input, message, Row, Select, Space, Tag, Typography } from 'antd';
import { Button, Card, Col, Input, message, Row, Select, Tag, Typography } from 'antd';
import { generateCopy, getAiModels, AiModelOption, CopyResponse } from '@/services/ai';
import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { copyText } from '@/utils/file';
const { Text, Paragraph } = Typography;
/** 标签轮换配色(antd 预设色) */
const TAG_PRESETS = ['magenta', 'red', 'volcano', 'orange', 'gold', 'lime', 'green', 'cyan', 'blue', 'geekblue', 'purple'];
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => Promise<void> | void;
/** 左右分栏 spanantd 24 栅格),默认 10/14(编辑页);试算页传 11/13 */
leftSpan?: number;
rightSpan?: number;
}
/** 左侧采集信息 + 模型/生成按钮,右侧推荐标题/简介/标签 */
export default function CopyPanel({ product, onSave }: Props) {
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
const [models, setModels] = useState<AiModelOption[]>([]);
const [model, setModel] = useState<string>('');
const [sourceText, setSourceText] = useState('');
@@ -66,25 +73,34 @@ export default function CopyPanel({ product, onSave }: Props) {
}
};
/** 回填标题:同时写入俄文 name 和中文 raw.title_zh */
/** 回填标题:同时写入俄文 name 和中文 raw.title_zh,并复制俄文标题到剪贴板 */
const applyTitle = async (titleRu: string, titleZh?: string) => {
const patch: Partial<ProductDetail> = { name: titleRu };
if (titleZh) {
patch.raw = { ...raw, title_zh: titleZh };
}
await onSave(patch);
message.success('标题已回填(中俄双语)');
const ok = await copyText(titleRu);
message.success(ok ? '已回填标题并复制俄文' : '已回填标题(复制失败,请手动复制)');
};
/** 回填俄文简介并复制到剪贴板 */
const applyDesc = async () => {
if (!result) return;
await onSave({ description: result.description_ru });
message.success('已回填俄文简介');
const ok = await copyText(result.description_ru);
message.success(ok ? '已回填俄文简介并复制' : '已回填俄文简介(复制失败,请手动复制)');
};
/** 点击标签:复制俄文标签 */
const copyTag = async (tagRu: string) => {
const ok = await copyText(tagRu);
ok ? message.success(`已复制:${tagRu}`) : message.error('复制失败,请手动复制');
};
return (
<Row gutter={16}>
<Col span={10}>
<Col span={leftSpan}>
<Card size="small" title="采集信息(AI 输入)">
{/* 显示中文原标题作为参考 */}
{((raw.title_zh as string) || (raw.title as string)) && (
@@ -96,7 +112,7 @@ export default function CopyPanel({ product, onSave }: Props) {
</div>
)}
<Input.TextArea
rows={12}
rows={16}
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="采集的商品资料(可编辑后生成)"
@@ -114,7 +130,7 @@ export default function CopyPanel({ product, onSave }: Props) {
</div>
</Card>
</Col>
<Col span={14}>
<Col span={rightSpan}>
<Card size="small" title="生成结果">
{!result ? (
<div style={{ color: '#999' }}></div>
@@ -142,24 +158,36 @@ export default function CopyPanel({ product, onSave }: Props) {
ghost
onClick={() => applyTitle(t, result.titles_zh?.[i])}
>
</Button>
</div>
</div>
))}
</div>
<Space style={{ marginTop: 4 }}>
<Text strong></Text>
<Button size="small" onClick={applyDesc}>
</Button>
</Space>
<Paragraph
style={{ whiteSpace: 'pre-wrap', marginTop: 4, marginBottom: 4, fontSize: 13 }}
<div
style={{
marginTop: 4,
marginBottom: 4,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// 与推荐标题灰底卡片(padding: 8)内的按钮右缘对齐
paddingRight: 8,
}}
>
{result.description_ru}
</Paragraph>
<Text strong></Text>
<Button size="small" type="primary" ghost onClick={applyDesc}>
</Button>
</div>
{/* 文本域展示(无滚动条自动撑高):复制时保留完整换行样式 */}
<Input.TextArea
autoSize={{ minRows: 8 }}
style={{ marginTop: 4, marginBottom: 4, fontSize: 13, overflow: 'hidden', resize: 'none' }}
value={result.description_ru}
readOnly
/>
<Paragraph
type="secondary"
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginBottom: 8 }}
@@ -168,14 +196,22 @@ export default function CopyPanel({ product, onSave }: Props) {
</Paragraph>
<Text strong></Text>
<div style={{ marginTop: 4, marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11, marginLeft: 8 }}>
</Text>
<div style={{ marginTop: 8, marginBottom: 4, display: 'flex', flexWrap: 'wrap', rowGap: 12, columnGap: 8 }}>
{result.tags_ru.map((t, i) => (
<Tag key={i}>{t}</Tag>
<Tag
key={i}
color={TAG_PRESETS[i % TAG_PRESETS.length]}
style={{ cursor: 'pointer', marginInlineEnd: 0 }}
onClick={() => copyTag(t)}
>
{t}
{result.tags_zh?.[i] ? <span> {result.tags_zh[i]}</span> : null}
</Tag>
))}
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{result.tags_zh?.join('、')}
</Text>
</div>
)}
</Card>
+14 -14
View File
@@ -33,16 +33,16 @@ function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
}
/**
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/预留折扣)。
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线价倍数)。
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 预留折扣前价格)。
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 销售价 × (1 + 倍数%))。
*/
export default function PriceInfoPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
const [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
@@ -67,7 +67,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
const tdPrice = pricing.tdPrice ?? 3;
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) =>
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
@@ -75,17 +75,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
discountReserve: patch.reserve ?? reserve,
lineMultiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => {
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
level: patch.level ?? level,
reserve: patch.reserve ?? reserve,
multiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
};
const r = compute(patch);
@@ -98,7 +98,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
discountReserve: p.reserve,
lineMultiplier: p.multiplier,
fxRate: p.fxRate,
logisticsFee: r.logisticsFee,
fullCommission: r.fullCommission,
@@ -109,7 +109,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
},
fx_rate: p.fxRate,
price: r.sellingPriceCny,
old_price: r.reservedPriceCny,
old_price: r.linePriceCny,
currency_code: 'CNY',
});
};
@@ -189,15 +189,15 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
/>
</Col>
<Col span={5}>
<FieldLabel> %</FieldLabel>
<FieldLabel>线 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={95}
value={reserve}
max={500}
value={multiplier}
onChange={(v) => {
setReserve(v ?? 0);
recalc({ reserve: v ?? 0 });
setMultiplier(v ?? 0);
recalc({ multiplier: v ?? 0 });
}}
/>
</Col>
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, imageEditSingle } from '@/services/suite';
const { Text } = Typography;
interface Props {
open: boolean;
productId: string;
/** 待生成的源图(采集图或生成图) */
source: { url: string; name: string } | null;
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
}
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-editdocs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated }: Props) {
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
// 每次打开重置(保留模型选择,方便连续精修)
useEffect(() => {
if (open) {
setPrompt('');
setResultUrl('');
setGenerating(false);
}
}, [open]);
const onGenerate = async () => {
if (!source) return;
if (!prompt.trim()) {
message.warning('请先输入生图要求');
return;
}
setGenerating(true);
setResultUrl('');
try {
const r = await imageEditSingle({
product_id: productId,
image_url: source.url,
prompt: prompt.trim(),
model,
append: true,
});
setResultUrl(r.url);
message.success('生成完成,已追加到「生成图」分组');
onGenerated?.(r.url);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setGenerating(false);
}
};
return (
<>
{open && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
background: '#fff',
borderRadius: 10,
padding: 20,
width: 760,
maxWidth: '92vw',
maxHeight: '88vh',
overflow: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 15 }}>AI </Text>
<Text type="secondary" style={{ fontSize: 12, maxWidth: 360 }} ellipsis={{ tooltip: source?.name }}>
{source?.name}
</Text>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Image
src={source?.url}
alt="原图"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Input.TextArea
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'}
disabled={generating}
/>
<Space style={{ marginTop: 12 }} wrap>
<Select
style={{ width: 260 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<Button type="primary" loading={generating} onClick={onGenerate}>
</Button>
<Button onClick={onClose}></Button>
</Space>
<Alert
style={{ marginTop: 16 }}
type="info"
showIcon
message="生成结果会自动追加到「生成图」分组;GPT 系列单张 1-5 分钟,请耐心等待"
/>
</div>
</div>
{resultUrl && (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong></Text>
<a href={resultUrl} target="_blank" rel="noreferrer">
</a>
</Space>
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
</div>
)}
</div>
</div>
)}
</>
);
}
+382
View File
@@ -0,0 +1,382 @@
/**
* 7 入库与导出(对齐 v1 web/ozonSeller.html 上品登记表):
* 「录入当前商品」把当前试算商品登记进当日登记表(localStorage,v1 同款交互),
* 表格展示当日录入的商品(17 列,物流费/平台总抽成/重量/尺寸/状态为可选列),
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
*/
import { useCallback, useEffect, useState } from 'react';
import { Button, Input, message, Modal, Popconfirm, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { copyText, downloadCsv } from '@/utils/file';
const { Text } = Typography;
/** v1 按钮/金额配色 */
const COLOR_SECONDARY = '#10b981'; // v1 bg-secondary(录入按钮)
const RECORDS_KEY = 'trialRegisterRecords';
interface RegisterRecord {
registeredAt: string; // 录入时间(当日过滤用)
sku: string;
modelCode: string;
productName: string;
status: string;
purchaseUrl: string;
sellingPrice: string;
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
sellingPriceRub: string;
sellingPriceRubReserved: string; // 划线价 ₽
lineMultiplier: number; // 划线价倍数 %v1 discountReserve 位)
exchangeRate: string;
logisticsFee: string;
receivedPrice: string;
purchasePrice: string;
profit: string;
profitRate: string;
fullCommission: string;
totalCost: string;
weight: string;
dimensions: string;
logisticsLevel: string; // 低 / 高 / Premium
}
function loadRecords(): RegisterRecord[] {
try {
const s = localStorage.getItem(RECORDS_KEY);
if (s) return JSON.parse(s) as RegisterRecord[];
} catch { /* 忽略坏数据 */ }
return [];
}
function saveRecords(records: RegisterRecord[]) {
try {
localStorage.setItem(RECORDS_KEY, JSON.stringify(records));
} catch { /* 忽略存储失败 */ }
}
/** 是否今天(本地时区) */
function isToday(iso: string): boolean {
if (!iso) return false;
const d = new Date(iso);
const now = new Date();
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
}
const cny = (v: unknown) => {
const n = parseFloat(String(v ?? ''));
return isNaN(n) ? '--' : `¥ ${n.toFixed(2)}`;
};
const rub = (v: unknown) => {
const n = parseFloat(String(v ?? ''));
return isNaN(n) ? '--' : `${n.toFixed(2)}`;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toRecord(product: ProductDetail): RegisterRecord | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pr = (product.pricing ?? {}) as Record<string, any>;
const raw = (product.raw ?? {}) as Record<string, unknown>;
if (!pr.calculatedAt) return null;
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
const dim = (v: number | null | undefined) => (v == null ? '' : String(v * scale));
const level = (pr.logisticsLevel as string) ?? 'low';
const f = (v: unknown, digits = 2) => (v == null ? '' : Number(v).toFixed(digits));
return {
registeredAt: new Date().toISOString(),
sku: product.offer_id || '',
modelCode: (raw.model_code as string) ?? '',
productName: ((raw.title_zh as string) ?? (raw.title as string) ?? product.name ?? '').trim(),
status: '在售',
purchaseUrl: (raw.purchase_url as string) ?? '',
sellingPrice: f(pr.sellingPriceCny),
sellingPriceReserved: f(pr.linePriceCny),
sellingPriceRub: f(pr.sellingPriceRub),
sellingPriceRubReserved: f(pr.linePriceRub),
lineMultiplier: pr.lineMultiplier ?? 100,
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
logisticsFee: f(pr.logisticsFee),
receivedPrice: f(pr.receivedPrice),
purchasePrice: f(pr.purchasePrice),
profit: f(pr.profitPrice),
profitRate: pr.profitRate != null ? String(Math.round(Number(pr.profitRate))) : '',
fullCommission: f(pr.fullCommission),
totalCost: f(pr.totalCost),
weight: product.weight != null ? String(product.weight) : '',
dimensions: product.depth != null && product.width != null && product.height != null
? `${dim(product.depth)}x${dim(product.width)}x${dim(product.height)}`
: '',
logisticsLevel: level === 'high' ? '高' : level === 'high2' ? 'Premium' : '低',
};
}
const CSV_HEADER = [
'货号(sku)', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
'净利润', '净利率', '卢布销价', '重量', '尺寸', '状态', 'Ozon地址', '采买地址',
];
function recordCsvRow(r: RegisterRecord): Array<string | number> {
return [
r.sku || '',
r.productName || '',
r.purchasePrice || '',
r.logisticsFee || '',
r.fullCommission || '',
r.receivedPrice || '',
r.totalCost || '',
r.sellingPrice || '',
r.profit || '',
r.profitRate ? `${r.profitRate}%` : '',
r.sellingPriceRub || '',
r.weight || '',
r.dimensions || '',
r.status || '在售',
r.sku ? `https://www.ozon.ru/product/${r.sku}` : '',
r.purchaseUrl || '',
];
}
/** 组合码一行:`货号 划线₽价(两位)`,无划线价回退销售价₽ */
function comboLine(r: RegisterRecord): string {
const rubPrice = r.sellingPriceRubReserved || r.sellingPriceRub;
if (!r.sku || !rubPrice) return '';
return `${r.sku} ${Number(rubPrice).toFixed(2)}`;
}
interface Props {
product: ProductDetail;
}
export default function TrialExportPanel({ product }: Props) {
const [records, setRecords] = useState<RegisterRecord[]>([]);
const [showOptional, setShowOptional] = useState(false);
const [comboOpen, setComboOpen] = useState(false);
const [comboText, setComboText] = useState('');
useEffect(() => {
setRecords(loadRecords().filter((r) => isToday(r.registeredAt)));
}, []);
const currentRecord = toRecord(product);
/** 录入当前商品(对齐 v1 recordData:货号必填、先计价、当日货号去重) */
const registerCurrent = useCallback(() => {
if (!currentRecord) {
message.warning('请先在「2 价格试算」完成计价,再录入商品');
return;
}
if (!currentRecord.sku) {
message.warning('请先填写货号(SKU),再录入商品');
return;
}
const list = loadRecords().filter((r) => isToday(r.registeredAt));
const dup = list.findIndex((r) => r.sku.trim().toLowerCase() === currentRecord.sku.trim().toLowerCase());
if (dup !== -1) {
message.warning(`货号「${currentRecord.sku}」已存在于当日登记表(第 ${dup + 1} 条),请勿重复录入`);
return;
}
const next = [currentRecord, ...list];
saveRecords(next);
setRecords(next);
message.success(`商品「${currentRecord.productName || currentRecord.sku}」录入成功`);
}, [currentRecord]);
const deleteRecord = (sku: string) => {
const next = loadRecords().filter((r) => r.sku !== sku);
saveRecords(next);
setRecords(next.filter((r) => isToday(r.registeredAt)));
};
const clearRecords = () => {
saveRecords([]);
setRecords([]);
message.success('登记表已清空');
};
const openCombo = () => {
if (records.length === 0) {
message.warning('登记表为空,请先录入商品');
return;
}
const lines = records.map(comboLine).filter(Boolean);
setComboText(lines.join('\n'));
setComboOpen(true);
};
const exportCsv = () => {
if (records.length === 0) {
message.warning('登记表为空,请先录入商品');
return;
}
const d = new Date();
const p = (v: number) => String(v).padStart(2, '0');
downloadCsv(
[CSV_HEADER, ...records.map(recordCsvRow)],
`${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}.csv`,
);
message.success(`已导出 ${records.length} 条记录`);
};
const buildColumns = (): ColumnsType<RegisterRecord> => {
const money = (field: keyof RegisterRecord, fmt: (v: unknown) => string) => ({
dataIndex: field,
render: (v: unknown) => <Text style={{ fontSize: 13 }}>{fmt(v)}</Text>,
});
const base: ColumnsType<RegisterRecord> = [
{
title: '操作',
width: 70,
render: (_, r) => (
<Popconfirm title="删除这条记录?" onConfirm={() => deleteRecord(r.sku)}>
<Button size="small" danger ghost type="primary"></Button>
</Popconfirm>
),
},
{ title: '货号(sku)', dataIndex: 'sku', width: 110, render: (v: string) => <Text style={{ fontSize: 13 }}>{v || '--'}</Text> },
{
title: '商品名',
dataIndex: 'productName',
ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 13 }}>{v || '--'}</Text>,
},
{ title: '进货价', width: 90, ...money('purchasePrice', cny) },
];
const optional: ColumnsType<RegisterRecord> = [
{ title: '物流费', width: 95, ...money('logisticsFee', cny) },
{ title: '平台总抽成', width: 105, ...money('fullCommission', cny) },
];
const rest: ColumnsType<RegisterRecord> = [
{ title: '实收价', width: 90, ...money('receivedPrice', cny) },
{ title: '完全成本', width: 95, ...money('totalCost', cny) },
{ title: '销售价', width: 95, ...money('sellingPrice', cny) },
{ title: '净利润', width: 90, ...money('profit', cny) },
{ title: '净利率', dataIndex: 'profitRate', width: 80, render: (v: string) => <Text style={{ fontSize: 13 }}>{v ? `${v}%` : '--'}</Text> },
{ title: '卢布销价', width: 100, ...money('sellingPriceRub', rub) },
];
const optional2: ColumnsType<RegisterRecord> = [
{ title: '重量', dataIndex: 'weight', width: 85, render: (v: string) => <Text style={{ fontSize: 13 }}>{v ? `${v} g` : '--'}</Text> },
{ title: '尺寸', dataIndex: 'dimensions', width: 120, render: (v: string) => <Text style={{ fontSize: 13 }}>{v ? `${v} cm` : '--'}</Text> },
{ title: '状态', dataIndex: 'status', width: 75, render: (v: string) => <Text style={{ fontSize: 13 }}>{v || '在售'}</Text> },
];
const links: ColumnsType<RegisterRecord> = [
{
title: 'Ozon地址',
dataIndex: 'sku',
width: 160,
render: (sku: string) =>
sku ? (
<a href={`https://www.ozon.ru/product/${sku}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
{`https://www.ozon.ru/product/${sku}`}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
},
{
title: '采买地址',
dataIndex: 'purchaseUrl',
width: 180,
ellipsis: true,
render: (v: string) =>
v ? (
<a href={v.startsWith('http') ? v : `https://${v}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }} title={v}>
{v}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
},
];
return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links];
};
const registerButton = (fixed = false) => (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={registerCurrent}
style={
fixed
? {
position: 'fixed',
right: 24,
bottom: 24,
zIndex: 100,
background: COLOR_SECONDARY,
height: 40,
paddingInline: 16,
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
}
: { background: COLOR_SECONDARY }
}
>
</Button>
);
return (
<div>
{/* 工具条:左=录入按钮;右=可选列切换/导出/清空(按钮配色对齐 v1) */}
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
{registerButton()}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<Button
type={showOptional ? 'primary' : 'default'}
ghost={showOptional}
onClick={() => setShowOptional((v) => !v)}
>
////
</Button>
<Button type="primary" ghost icon={<CopyOutlined />} onClick={openCombo}>
</Button>
<Button type="primary" ghost icon={<FileTextOutlined />} onClick={exportCsv}>
</Button>
<Popconfirm title="确认表格已经导出!此操作将清空已有数据。" onConfirm={clearRecords}>
<Button danger ghost icon={<RestOutlined />}></Button>
</Popconfirm>
</div>
</div>
<Table
rowKey="sku"
size="small"
columns={buildColumns()}
dataSource={records}
pagination={false}
scroll={{ x: 'max-content' }}
locale={{ emptyText: '当日暂无录入记录,点击「录入当前商品」登记' }}
/>
{/* 组合码弹窗(对齐 v1 上品组合码 textarea */}
<Modal
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
open={comboOpen}
onCancel={() => setComboOpen(false)}
footer={[
<Button
key="copy"
type="primary"
style={{ background: COLOR_SECONDARY }}
icon={<DownloadOutlined />}
onClick={async () => {
const ok = await copyText(comboText);
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
}}
>
</Button>,
<Button key="close" onClick={() => setComboOpen(false)}></Button>,
]}
>
<Input.TextArea rows={10} readOnly value={comboText} style={{ fontFamily: 'monospace' }} />
</Modal>
{/* 页面右下角固定录入按钮(不随页面滚动) */}
{registerButton(true)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useState } from 'react';
import { Col, Input, message, Row, Typography } from 'antd';
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { copyText } from '@/utils/file';
import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
const { Text } = Typography;
/** 采集平台 → 是否自动填入采买地址(仅国内采购站;Ozon 等海外平台不填) */
const PURCHASE_PLATFORMS = new Set(['1688', 'taobao', 'tmall', 'pdd']);
const isPurchasePlatform = (platform: string | null | undefined) =>
!!platform && PURCHASE_PLATFORMS.has(platform.toLowerCase());
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => void;
}
/**
* 01 商品信息:采集结果核对/补录。编辑失焦即落库(父级 PATCH)。
* 左右两列:左=中文标题/采集价/型号/货号/采买地址,右=俄文标题/商品描述/规格参数。
* 重量/尺寸在「02 价格试算」左列维护;采买地址仅国内采购平台自动填入。
*/
export default function TrialInfoPanel({ product, onSave }: Props) {
const raw = (product.raw ?? {}) as Record<string, unknown>;
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
const [nameRu, setNameRu] = useState(product.name ?? '');
const [modelCode, setModelCode] = useState((raw.model_code as string) ?? '');
const [offerId, setOfferId] = useState(product.offer_id ?? '');
// 采买地址:仅 1688/拼多多/淘宝/天猫 来源时用 source_url 初始化
const [purchaseUrl, setPurchaseUrl] = useState(
(raw.purchase_url as string) ?? (isPurchasePlatform(product.source_platform) ? (product.source_url ?? '') : ''),
);
const [desc, setDesc] = useState((raw.desc as string) ?? '');
const patchRaw = (patch: Record<string, unknown>) => onSave({ raw: { ...raw, ...patch } });
const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
/** 型号变化:货号前缀始终同步为新型号(对齐 v1 web:货号 = 型号-后缀)。
* 货号为空 → 带入「型号-」;货号非空 → 替换第一个「-」前的前缀、保留后缀;
* 型号清空时货号保持不动(避免误删已填后缀)。 */
const onModelChange = (v: string) => {
setModelCode(v);
setOfferId((prev) => {
if (!prev) return v ? `${v}-` : '';
if (!v) return prev;
const idx = prev.indexOf('-');
const suffix = idx >= 0 ? prev.slice(idx) : '-';
return `${v}${suffix}`;
});
};
const openUrl = purchaseUrl?.trim()
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
: '';
const copyPurchaseUrl = () => {
copyText(purchaseUrl).then((ok) => ok && message.success('已复制'));
};
return (
<Row gutter={24}>
{/* ── 左列:中文标题 / 采集价 / 型号 / 货号 / 采买地址 ── */}
<Col span={11}>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Input
value={titleZh}
placeholder="采集原标题可在此整理为中文"
onChange={(e) => setTitleZh(e.target.value)}
onBlur={() => patchRaw({ title_zh: titleZh.trim() })}
/>
</div>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{raw.price ? String(raw.price) : '—'}</Text>
</div>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel></FieldLabel>
<Input
value={modelCode}
placeholder="如 YZ"
onChange={(e) => onModelChange(e.target.value)}
onBlur={() => patchRaw({ model_code: modelCode.trim() })}
/>
</Col>
<Col span={12}>
<FieldLabel>SKU</FieldLabel>
<Input
value={offerId}
placeholder="型号-后缀(型号自动带入前缀)"
onChange={(e) => setOfferId(e.target.value)}
onBlur={() => onSave({ offer_id: offerId.trim() })}
/>
</Col>
</Row>
<div style={{ ...fieldRowStyle, marginBottom: 0 }}>
<FieldLabel>1688 / </FieldLabel>
<Input
value={purchaseUrl}
placeholder="https://detail.1688.com/..."
onChange={(e) => setPurchaseUrl(e.target.value)}
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
addonAfter={
<span style={{ display: 'inline-flex', gap: 10 }}>
{openUrl && (
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址">
<ExportOutlined />
</a>
)}
<a title="复制采买地址" onClick={copyPurchaseUrl}>
<CopyOutlined />
</a>
</span>
}
/>
</div>
</Col>
{/* ── 右列:俄文标题 / 商品描述 / 规格参数 ── */}
<Col span={13}>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Input
value={nameRu}
onChange={(e) => setNameRu(e.target.value)}
onBlur={() => onSave({ name: nameRu })}
/>
</div>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Input.TextArea
rows={4}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onBlur={() => patchRaw({ desc })}
/>
</div>
{params.length > 0 && (
<div>
<FieldLabel> / {params.length} </FieldLabel>
<div
style={{
maxHeight: 300,
overflow: 'auto',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: '6px 12px',
background: '#fafafa',
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', columnGap: 24 }}>
{params.slice(0, 60).map((p, i) => (
<div key={i} style={{ display: 'flex', borderBottom: '1px solid #f0f0f0', fontSize: 12, lineHeight: '28px' }}>
<span style={{ color: '#888', whiteSpace: 'nowrap', marginRight: 8, flexShrink: 0 }}>{p.key}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={p.value}>
{p.value}
</span>
</div>
))}
</div>
</div>
</div>
)}
</Col>
</Row>
);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* 商品试算页(docs/v2.1/trial-page.md):采集后的主工作流。
* 01 商品信息 → 02 价格试算 → 03 俄文文案 → 04 图片与AI生图 → 05 入库与导出。
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
*/
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
import TrialInfoPanel from './TrialInfoPanel';
import TrialPricingPanel from './TrialPricingPanel';
import TrialSuitePanel from './TrialSuitePanel';
import TrialExportPanel from './TrialExportPanel';
import CopyPanel from '../product/CopyPanel';
const { Title, Text } = Typography;
const SECTIONS = [
{ id: 'section-info', label: '商品信息' },
{ id: 'section-pricing', label: '价格试算' },
{ id: 'section-copy', label: '俄文文案' },
{ id: 'section-collect', label: '采集图片' },
{ id: 'section-plan', label: '出图方案' },
{ id: 'section-result', label: '生成结果' },
{ id: 'section-export', label: '入库与导出' },
];
export default function TrialPage() {
const { id } = useParams();
const [product, setProduct] = useState<ProductDetail | null>(null);
const [assets, setAssets] = useState<ProductAsset[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [active, setActive] = useState(SECTIONS[0].id);
const load = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const [p, a] = await Promise.all([getProduct(id), listAssets(id)]);
setProduct(p);
setAssets(a);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
load();
}, [load]);
// 滚动监听:高亮当前区域
useEffect(() => {
const onScroll = () => {
let current = SECTIONS[0].id;
for (const s of SECTIONS) {
const el = document.getElementById(s.id);
if (el && el.getBoundingClientRect().top <= 90) current = s.id;
}
setActive(current);
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const save = useCallback(
async (partial: Partial<ProductDetail>) => {
if (!id || !product) return;
setSaving(true);
try {
const updated = await updateProduct(id, partial);
setProduct(updated);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setSaving(false);
}
},
[id, product],
);
if (loading || !product) {
return (
<div style={{ padding: 80, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
}
const raw = (product.raw ?? {}) as Record<string, unknown>;
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
return (
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<Title level={4} style={{ margin: 0 }}>
{titleZh || product.name || '(未命名商品)'}
</Title>
<span style={{ color: STAGE_COLOR[product.stage] }}>
{STAGE_LABEL[product.stage] || product.stage}
</span>
</Space>
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
</div>
<Space size={16} style={{ marginTop: 4 }}>
{product.name && product.name !== titleZh && (
<Text type="secondary" ellipsis={{ tooltip: product.name }} style={{ maxWidth: 420 }}>
{product.name}
</Text>
)}
<Link to={`/product/${product.id}`}> </Link>
</Space>
</div>
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
<div id="section-info">
<Card title="1 商品信息">
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
</Card>
</div>
<div id="section-pricing">
<Card title="2 价格试算">
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
</Card>
</div>
<div id="section-copy">
<Card title="3 俄文文案(AI">
<CopyPanel product={product} onSave={save} leftSpan={11} rightSpan={13} />
</Card>
</div>
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
<div id="section-export">
<Card title="7 入库与导出">
<TrialExportPanel product={product} />
</Card>
</div>
</div>
{/* 右侧区域导航 */}
<div style={{ width: 120, flexShrink: 0 }}>
<div style={{ position: 'sticky', top: 80 }}>
<Menu
mode="inline"
selectedKeys={[active]}
style={{ borderInlineEnd: 0, background: 'transparent' }}
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
onClick={({ key }) => {
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,527 @@
import { useEffect, useState, type CSSProperties } from 'react';
import { Alert, Button, Card, Col, InputNumber, message, Radio, Row, Space, Typography } from 'antd';
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { getFxRate } from '@/services/fx';
import { apiErrorMessage } from '@/services/api';
import { copyText } from '@/utils/file';
import {
calculatePricing,
logisticsFeeRule,
validateDimensions,
validateLogisticsLevelCny,
validatePriceRange,
type LogisticsLevel,
type PricingResult,
} from '@/pricing/pricing';
import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
const { Text } = Typography;
/** v1 web/ozonSeller.html 颜色:完全成本/平台总抽成 text-red-400,销售价 text-secondary */
const COLOR_RED = '#f87171';
const COLOR_GREEN = '#10b981';
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => void;
}
type PricingPayload = Record<string, any>;
type Dims = { l: number | null; w: number | null; h: number | null };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number; weightG: number; dims: Dims }>;
function dimsCm(product: ProductDetail): Dims {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
const cv = (v: number | null | undefined) => (v == null ? null : Number((v * scale).toFixed(2)));
return { l: cv(product.depth ?? null), w: cv(product.width ?? null), h: cv(product.height ?? null) };
}
/** 计价输入用:空值按 0 */
const wG = (v: number | null): number => v ?? 0;
const dimsNum = (d: Dims): { l: number; w: number; h: number } => ({ l: d.l ?? 0, w: d.w ?? 0, h: d.h ?? 0 });
/** 结果指标:可指定价格颜色(v1 完全成本/总抽成红色、销售价绿色) */
function Metric({ label, value, rule, color }: { label: string; value: string; rule?: string; color?: string }) {
return (
<div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 600, lineHeight: '28px', color: color ?? undefined }}>{value}</div>
{rule && <div style={{ fontSize: 11, color: 'rgba(0,0,0,0.35)' }}>{rule}</div>}
</div>
);
}
/** 销售价格四宫格:人民币/卢布 × 销售价/划线价,价格居中,划线价带删除线;人民币销售价可一键复制 */
function SalePriceGrid({
cnySelling,
cnySellingNumber,
cnyLine,
rubSelling,
rubLine,
multiplier,
fxRate,
cnyGap,
rubGap,
}: {
cnySelling: string;
cnySellingNumber?: string;
cnyLine: string;
rubSelling: string;
rubLine: string;
multiplier: number;
fxRate: number;
cnyGap: number | null;
rubGap: number | null;
}) {
const cellStyle: CSSProperties = {
textAlign: 'center',
background: '#fff',
borderRadius: 8,
padding: '12px 8px',
};
const priceStyle: CSSProperties = {
fontSize: 20,
fontWeight: 700,
color: COLOR_GREEN,
lineHeight: '30px',
};
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' };
const multiple = (1 + multiplier / 100).toFixed(2);
const copyCnySelling = async () => {
if (!cnySellingNumber) return;
const ok = await copyText(cnySellingNumber);
ok ? message.success(`已复制:${cnySellingNumber}`) : message.error('复制失败');
};
return (
<div style={{ borderTop: '1px dashed #e5e7eb', paddingTop: 14, marginTop: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div style={cellStyle}>
<div
style={{
fontSize: 13,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
}}
>
-
{cnySellingNumber && (
<CopyOutlined
title="复制人民币销售价"
style={{ color: 'rgba(0,0,0,0.45)', cursor: 'pointer' }}
onClick={copyCnySelling}
/>
)}
</div>
<div style={priceStyle}>{cnySelling}</div>
<div style={noteStyle}></div>
</div>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-</div>
<div style={priceStyle}>{rubSelling}</div>
<div style={noteStyle}>
1 ¥ = {fxRate ? fxRate.toFixed(2) : '--'}
</div>
</div>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-线</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
<div style={noteStyle}>
× {multiple}
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
</div>
</div>
<div style={cellStyle}>
<div style={{ fontSize: 13, fontWeight: 500 }}>-线</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
<div style={noteStyle}>
× {multiple}
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
</div>
</div>
</div>
</div>
);
}
/**
* 02 价格试算:公式与告警完整移植 v1 web/js/app.jstrial-page.md §4)。
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
* 划线价为「倍数」口径:划线价 = 销售价 × (1 + 倍数%),0-500% 线性可算(v2.1 调整)。
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
* 尺寸不合规时不落计价结果。
*/
export default function TrialPricingPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
const [fxSource, setFxSource] = useState('');
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
const [weight, setWeight] = useState<number | null>(
product.weight != null ? (product.weight_unit === 'kg' ? product.weight * 1000 : product.weight) : null,
);
const [dims, setDims] = useState<Dims>(dimsCm(product));
// 币种固定人民币(历史数据默认 RUB 时纠正一次)
useEffect(() => {
if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 汇率:无快照时拉取并立即计价入库(试算页首次打开即完成一次入库)
useEffect(() => {
if (!fxRate) {
getFxRate()
.then((r) => {
setFxRate(r.rate);
setFxSource(r.source);
recalc({ fxRate: r.rate });
})
.catch((e) => message.error(apiErrorMessage(e)));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const refreshFx = async () => {
try {
const r = await getFxRate();
setFxRate(r.rate);
setFxSource(r.source);
recalc({ fxRate: r.rate });
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const compute = (patch: PricingState = {}): PricingResult =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level,
weightG: wG(patch.weightG ?? weight),
dims: dimsNum(patch.dims ?? dims),
tdPrice: patch.tdPrice ?? tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
tdPrice: patch.tdPrice ?? tdPrice,
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
weightG: wG(patch.weightG ?? weight),
dimsNow: dimsNum(patch.dims ?? dims),
};
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level);
const r = dimError ? null : calculatePricing({
purchasePrice: p.purchasePrice,
profitRate: p.profitRate,
logisticsLevel: p.level,
weightG: p.weightG,
dims: p.dimsNow,
tdPrice: p.tdPrice,
lineMultiplier: p.multiplier,
fxRate: p.fxRate,
});
onSave({
...extraSave,
pricing: {
...pricing,
purchasePrice: p.purchasePrice,
profitRate: p.profitRate,
tdPrice: p.tdPrice,
logisticsLevel: p.level,
lineMultiplier: p.multiplier,
fxRate: p.fxRate,
weightG: p.weightG,
dims: p.dimsNow,
...(r
? {
logisticsFee: r.logisticsFee,
receivedPrice: r.receivedPrice,
profitPrice: r.profitPrice,
commission: r.commission,
fullCommission: r.fullCommission,
totalCost: r.totalCost,
sellingPriceCny: r.sellingPriceCny,
linePriceCny: r.linePriceCny,
sellingPriceRub: r.sellingPriceRub,
linePriceRub: r.linePriceRub,
calculatedAt: new Date().toISOString(),
}
: {}),
},
fx_rate: p.fxRate,
...(r ? { price: r.sellingPriceCny, old_price: r.linePriceCny } : {}),
currency_code: 'CNY',
});
};
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
const onWeightChange = (v: number | null) => {
setWeight(v);
recalc({ weightG: v ?? 0 }, { weight: v, weight_unit: 'g' });
};
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
const onDimChange = (key: 'l' | 'w' | 'h', v: number | null) => {
const next = { ...dims, [key]: v };
setDims(next);
const field = key === 'l' ? 'depth' : key === 'w' ? 'width' : 'height';
recalc(
{ dims: next },
{ [field]: v == null ? null : v * 10, dimension_unit: 'mm' },
);
};
const curWeightG = wG(weight);
const curDims = dimsNum(dims);
const dimError = fxRate ? validateDimensions(curWeightG, curDims, level) : '';
const preview = fxRate && !dimError ? compute() : null;
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
const num = (v: number | undefined | null, digits = 2) => (v == null ? '--' : v.toFixed(digits));
return (
<Row gutter={24}>
{/* ── 左:操作项(重量/尺寸 → 物流等级) ── */}
<Col span={11}>
<Row gutter={16} style={fieldRowStyle}>
<Col span={6}>
<FieldLabel> g</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={weight}
onChange={(v) => onWeightChange(v)}
/>
</Col>
<Col span={6}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.l}
onChange={(v) => onDimChange('l', v)}
/>
</Col>
<Col span={6}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.w}
onChange={(v) => onDimChange('w', v)}
/>
</Col>
<Col span={6}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.h}
onChange={(v) => onDimChange('h', v)}
/>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={purchasePrice}
onChange={(v) => {
const n = v ?? 0;
setPurchasePrice(n);
recalc({ purchasePrice: n });
}}
/>
</Col>
<Col span={12}>
<FieldLabel> %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={profitRate}
onChange={(v) => {
const n = v ?? 0;
setProfitRate(n);
recalc({ profitRate: n });
}}
/>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={tdPrice}
onChange={(v) => {
const n = v ?? 0;
setTdPrice(n);
recalc({ tdPrice: n });
}}
/>
</Col>
<Col span={12}>
<FieldLabel>线 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
step={5}
value={multiplier}
onChange={(v) => {
const n = v ?? 0;
setMultiplier(n);
recalc({ multiplier: n });
}}
/>
</Col>
</Row>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Radio.Group
block
optionType="button"
buttonStyle="solid"
value={level}
onChange={(e) => {
const v = e.target.value as LogisticsLevel;
setLevel(v);
recalc({ level: v });
}}
options={[
{ value: 'low', label: '低 (low)' },
{ value: 'high', label: '高 (high)' },
{ value: 'high2', label: 'Premium (high2)' },
]}
/>
</div>
{/* 汇率极少手动改(默认自动获取),放最后 */}
<Row gutter={16} style={fieldRowStyle}>
<Col span={13}>
<FieldLabel>
¥{' '}
{fxSource && (
<Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>
({fxSource})
</Text>
)}
</FieldLabel>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
min={0}
value={fxRate || undefined}
onChange={(v) => {
const n = v ?? 0;
setFxRate(n);
recalc({ fxRate: n });
}}
/>
<Button icon={<ReloadOutlined />} onClick={refreshFx} title="刷新汇率" />
</Space.Compact>
</Col>
</Row>
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
线 = × (1 + %)/
</Text>
{(levelHint || rangeHint) && (
<div style={{ marginTop: 12 }}>
{levelHint && <Alert type="warning" showIcon message={levelHint} style={{ marginBottom: 8 }} />}
{rangeHint && <Alert type="warning" showIcon message={rangeHint} />}
</div>
)}
</Col>
{/* ── 右:展示项(物流费 → 销售价) ── */}
<Col span={13}>
<Card size="small" style={{ background: '#fafafa' }}>
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
<Row gutter={[16, 20]}>
<Col span={8}>
<Metric
label="物流费"
value={preview ? `¥ ${num(preview.logisticsFee)}` : '--'}
rule={preview ? `(${logisticsFeeRule(curWeightG, level, tdPrice)})` : ''}
/>
</Col>
<Col span={8}>
<Metric
label="平台佣金"
value={preview ? `¥ ${num(preview.commission)}` : '--'}
rule={level === 'low' ? '(销售价 × 12%)' : '(销售价 × 18%)'}
/>
</Col>
<Col span={8}>
<Metric
label="提现费及其他"
value={preview ? `¥ ${num(preview.fullCommission - preview.commission)}` : '--'}
rule="(平台总抽成 − 平台佣金,销售价 × 3.5%)"
color={COLOR_RED}
/>
</Col>
</Row>
{/* 第二行:完全成本 / 净利润 / 实收价 */}
<Row gutter={[16, 20]} style={{ marginTop: 4 }}>
<Col span={8}>
<Metric
label="完全成本"
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
rule="(进货价 + 物流费 + 平台总抽成)"
color={COLOR_RED}
/>
</Col>
<Col span={8}>
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" />
</Col>
<Col span={8}>
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" />
</Col>
</Row>
{/* 第三、四行:人民币/卢布 销售价 + 划线价 四宫格 */}
<SalePriceGrid
cnySelling={preview ? `¥ ${num(preview.sellingPriceCny)}` : '--'}
cnySellingNumber={preview ? preview.sellingPriceCny.toFixed(2) : undefined}
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
rubSelling={preview ? `${num(preview.sellingPriceRub, 0)}` : '--'}
rubLine={preview ? `${num(preview.linePriceRub, 0)}` : '--'}
multiplier={multiplier}
fxRate={fxRate}
cnyGap={preview ? discountGap : null}
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : null}
/>
</Card>
</Col>
</Row>
);
}
+887
View File
@@ -0,0 +1,887 @@
/**
* 04 图片与 AI 生图(docs/v2.1/image-suite.md):
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
import { ProductAsset, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import {
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
type PlanItem,
} from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal';
const { Text } = Typography;
const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark';
interface Props {
product: ProductDetail;
assets: ProductAsset[];
onRefreshAssets: () => void;
}
/** 商品信息 → 规划/生成的文本素材(包装字段覆盖回参数表,对齐 image-suite-studio editedTexts */
function buildSuiteTexts(product: ProductDetail): SuiteTextPayload[] {
const raw = (product.raw ?? {}) as Record<string, unknown>;
const texts: SuiteTextPayload[] = [];
const title = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
if (title) texts.push({ kind: 'title', content: title });
if (raw.price) texts.push({ kind: 'price', content: String(raw.price) });
(['brand', 'sales', 'shop'] as const).forEach((k) => {
const v = raw[k];
if (v) texts.push({ kind: k, content: String(v) });
});
const sellingPoint = (raw.selling_point as string) ?? (raw.sellingPoints as string);
if (sellingPoint) texts.push({ kind: 'selling_point', content: sellingPoint });
let pairs = Array.isArray(raw.params) ? [...(raw.params as Array<{ key: string; value: string }>)] : [];
pairs = pairs.filter((p) => !/尺寸|长宽高|重量/i.test(p.key));
if (product.weight != null && product.weight > 0) pairs.push({ key: '重量', value: `${product.weight}g` });
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
if (product.depth != null && product.width != null && product.height != null) {
const d = { l: product.depth * scale, w: product.width * scale, h: product.height * scale };
pairs.push({ key: '产品尺寸', value: `${d.l}×${d.w}×${d.h}` });
}
if (pairs.length) texts.push({ kind: 'params', content: '', pairs });
if (raw.desc) texts.push({ kind: 'desc', content: String(raw.desc) });
return texts;
}
function loadWatermark(): WatermarkPayload {
try {
const s = localStorage.getItem(WATERMARK_STORAGE_KEY);
if (s) return { ...DEFAULT_WATERMARK, ...(JSON.parse(s) as WatermarkPayload) };
} catch { /* 忽略坏数据 */ }
return DEFAULT_WATERMARK;
}
export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Props) {
// 素材选择(默认全选主图 + SKU 图,SKU 规格名供 AI 规划 variant 绑定)
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(assets.filter((a) => ['main', 'sku'].includes(a.group_key) && a.type !== 'video').map((a) => a.id)),
);
/** 上传成功待自动勾选的 asset id */
const pendingSelectRef = useRef<Set<string>>(new Set());
const [uploadingCount, setUploadingCount] = useState(0);
// 出图方案
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map((p) => ({ ...p })));
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
const [planSummary, setPlanSummary] = useState('');
const [planning, setPlanning] = useState(false);
const [planThenGenerate, setPlanThenGenerate] = useState(false);
const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const [exportingZip, setExportingZip] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重置后丢弃迟到的过期响应 */
const planSeqRef = useRef(0);
// 单张 AI 生图弹窗
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video'), [assets]);
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
// 上传完成的素材自动勾选(素材列表刷新后合并)
useEffect(() => {
if (pendingSelectRef.current.size === 0) return;
const next = new Set(selectedKeys);
let touched = false;
pendingSelectRef.current.forEach((id) => {
if (assets.some((a) => a.id === id) && !next.has(id)) {
next.add(id);
touched = true;
}
});
pendingSelectRef.current.clear();
if (touched) setSelectedKeys(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets]);
// 组件卸载停止轮询
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
}, []);
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
const toggleKey = (key: string) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleGroup = (g: string, on: boolean) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
groupImages(g).forEach((a) => (on ? next.add(a.id) : next.delete(a.id)));
return next;
});
};
const uploadButton = (
<Upload
accept="image/*"
multiple
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }) => {
setUploadingCount((c) => c + 1);
try {
const r = await uploadProductAsset(product.id, file as File);
pendingSelectRef.current.add(r.asset_id);
onSuccess?.(r);
} catch (e) {
message.error(`${(file as File).name}${apiErrorMessage(e)}`);
onError?.(e as Error);
} finally {
setUploadingCount((c) => c - 1);
onRefreshAssets();
}
}}
>
<Button icon={<UploadOutlined />} size="small" loading={uploadingCount > 0}>
</Button>
</Upload>
);
// ── 下载采集图片(勾选图打包 ZIP) ─────────────────────────────
const handleExportImages = async () => {
const selected = imgs.filter((a) => selectedKeys.has(a.id));
if (selected.length === 0) {
message.warning('请先勾选要下载的图片');
return;
}
setExportingImages(true);
try {
const blob = await exportImages({
title: productTitle,
images: selected.map((a) => ({
url: assetUrl(a),
groupName: GROUP_LABELS[a.group_key] ?? a.group_key,
variantName: a.variant_name,
key: a.id,
})),
});
downloadBlob(blob, `${cleanFilename(productTitle) || '采集图片'}.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingImages(false);
}
};
// ── AI 智能规划 ─────────────────────────────────────────────
const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? '';
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
const pollSuite = (suiteId: string) => {
stopPolling();
const startedAt = Date.now();
let failCount = 0;
let timeoutWarned = false;
pollRef.current = setInterval(async () => {
try {
const s = await getSuite(suiteId);
setSuite(s);
failCount = 0;
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) {
timeoutWarned = true;
message.warning('任务耗时异常,可能已中断(如服务端重启),可稍后观察或重新生成');
}
if (['done', 'partial', 'failed'].includes(s.status)) {
stopPolling();
setGenerating(false);
if (s.status === 'partial') message.warning(s.error || '部分生成失败,可重试或更换风格重新生成');
if (s.status === 'failed') message.error(s.error || '生成失败');
// Phase B 起服务端会把生成图回写素材(generated 组)
if (s.status !== 'failed') onRefreshAssets();
}
} catch {
failCount += 1;
if (failCount >= 3) {
stopPolling();
setGenerating(false);
message.error('连续查询生成进度失败,已停止跟踪;若服务端刚重启,请重新生成');
}
}
}, 3000);
};
const startGenerate = async (planOverride?: PlanItem[]): Promise<void> => {
if (selectedKeys.size === 0) {
message.warning('请先在采集图片区勾选参考底图');
return;
}
const activePlan = (planOverride ?? plan).filter((p) => p.count > 0);
if (activePlan.length === 0) {
message.warning('出图方案的张数都是 0');
return;
}
setGenerating(true);
setSuite(null);
try {
const { suite_id } = await generateSuite({
product_id: product.id,
texts: buildSuiteTexts(product),
images: imgs
.filter((a) => selectedKeys.has(a.id))
.map((a) => ({ url: assetUrl(a), group_key: a.group_key, variant_name: a.variant_name })),
style_set: styleSet,
style_prompt: currentStylePrompt || null,
requirements: requirements.trim() || null,
plan: activePlan,
platform: 'ozon',
model,
// 水印开启才下发,关闭时不带(服务端按无水印处理)
watermark: watermark.enabled ? watermark : undefined,
});
pollSuite(suite_id);
} catch (e) {
setGenerating(false);
message.error(apiErrorMessage(e));
}
};
const handlePlan = async () => {
const seq = ++planSeqRef.current;
setPlanning(true);
try {
const skuVariants = Array.from(
new Set(assets.filter((a) => a.group_key === 'sku' && a.variant_name).map((a) => a.variant_name!)),
);
const stats: Record<string, number> = {};
imgs.forEach((a) => {
stats[a.group_key] = (stats[a.group_key] ?? 0) + 1;
});
const data = await planSuite({
product_id: product.id,
texts: buildSuiteTexts(product),
sku_variants: skuVariants,
image_stats: stats,
platform: 'ozon',
requirements: requirements.trim() || null,
});
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
setPlan(data.items);
setPlanSource('ai');
setPlanSummary(data.summary);
if (planThenGenerate) {
await startGenerate(data.items);
} else {
const total = data.items.reduce((s, i) => s + i.count, 0);
message.info(`AI 方案已生成:${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`);
}
} catch (e) {
if (seq !== planSeqRef.current) return;
message.error(apiErrorMessage(e));
} finally {
if (seq === planSeqRef.current) setPlanning(false);
}
};
const handleGenerate = () => {
if (selectedKeys.size === 0) {
message.warning('请先在采集图片区勾选参考底图');
return;
}
if (totalPlanned === 0) {
message.warning('出图方案的张数都是 0');
return;
}
Modal.confirm({
title: '生成电商套图',
content: (
<div>
<div>
Ozon · 3:4 {model}
{STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.label} {totalPlanned} {' '}
{selectedKeys.size}
</div>
</div>
),
okText: '开始生成',
cancelText: '取消',
onOk: () => startGenerate(),
});
};
const handleExportZip = async () => {
if (!suite) return;
setExportingZip(true);
try {
const blob = await downloadSuiteZip(suite.id);
downloadBlob(blob, `${cleanFilename(productTitle) || '套图'}.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingZip(false);
}
};
const setPlanCount = (idx: number, count: number) => {
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p)));
};
const togglePlanRow = (idx: number) => {
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p)));
};
const planAllEnabled = plan.every((p) => p.count >= 1);
const togglePlanAll = (on: boolean) => {
setPlan((prev) => prev.map((p) => (on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 })));
};
const doneCount = suite?.images.filter((i) => i.status === 'ok').length ?? 0;
const suiteTotal = suite?.total ?? totalPlanned;
const patchWatermark = (patch: Partial<WatermarkPayload>) => {
setWatermark((prev) => {
const next = { ...prev, ...patch };
try {
localStorage.setItem(WATERMARK_STORAGE_KEY, JSON.stringify(next));
} catch { /* 忽略存储失败 */ }
return next;
});
};
const watermarkPopup = (
<div style={{ width: 280 }}>
<Checkbox
checked={watermark.enabled}
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
>
</Checkbox>
{watermark.enabled && (
<>
<div style={{ margin: '10px 0 6px' }}>
<Radio.Group
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(e) => patchWatermark({ type: e.target.value })}
options={[
{ value: 'image', label: '图片徽章' },
{ value: 'text', label: '文字' },
]}
/>
</div>
{watermark.type === 'text' && (
<Input
size="small"
value={watermark.text}
placeholder="水印文字"
onChange={(e) => patchWatermark({ text: e.target.value })}
/>
)}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
%
</Text>
<InputNumber
size="small"
style={{ width: '100%' }}
min={1}
max={100}
step={5}
value={watermark.opacity}
onChange={(v) => patchWatermark({ opacity: v ?? 30 })}
/>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
</Text>
</>
)}
</div>
);
const selectableCount = imgs.length;
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── 4 采集图片 ── */}
<div id="section-collect">
<Card
title={`4 采集图片(已选 ${selectedKeys.size} / ${selectableCount}`}
extra={
<Space>
{uploadButton}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingImages}
onClick={handleExportImages}
>
</Button>
</Space>
}
>
{displayGroups.length === 0 ? (
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
) : (
<Image.PreviewGroup>
{displayGroups.map((g) => (
<div key={g} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Text strong>
{GROUP_LABELS[g] ?? g}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>
{groupImages(g).length}
</Text>
</Text>
<a
style={{ fontSize: 12 }}
onClick={() => {
const all = groupImages(g).every((a) => selectedKeys.has(a.id));
toggleGroup(g, !all);
}}
>
{groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'}
</a>
{g === 'main' && (
<Text type="secondary" style={{ fontSize: 11 }}>
</Text>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id} style={{ width: 112, position: 'relative' }}>
<div
style={{
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: on ? 'rgba(22,119,255,0.06)' : undefined,
width: 'fit-content',
}}
>
<Image
src={assetUrl(a)}
width={100}
height={100}
style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
/>
</div>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
style={{
position: 'absolute',
top: 6,
left: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: on ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: on ? '#fff' : '#bbb',
border: '1px solid #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 1,
userSelect: 'none',
}}
>
</span>
{a.variant_name && (
<Tag
style={{ position: 'absolute', top: 6, right: 6, margin: 0, zIndex: 1 }}
>
{a.variant_name}
</Tag>
)}
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 40, left: 6, margin: 0, zIndex: 1 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
<Button
block
size="small"
style={{ marginTop: 4 }}
icon={<ThunderboltOutlined />}
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
>
AI
</Button>
</div>
);
})}
</div>
</div>
))}
</Image.PreviewGroup>
)}
</Card>
</div>
{/* ── 5 出图方案(左:规划操作+方案列表;右:风格/要求/模型/生成) ── */}
<div id="section-plan">
<Card
title={`5 出图方案(共 ${totalPlanned} 张)`}
extra={planSource === 'ai' && <Tag color="purple">AI </Tag>}
>
<Row gutter={24}>
<Col span={12}>
{/* 左列整块:与价格试算右列相同的浅灰卡片 */}
<Card size="small" style={{ background: '#fafafa' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Checkbox
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
title="勾选后,AI 规划完成将自动开始生成"
>
</Checkbox>
<Button
size="small"
type="primary"
ghost
icon={<ThunderboltOutlined />}
loading={planning}
onClick={handlePlan}
>
AI
</Button>
</div>
<div>
{plan.map((p, idx) => (
<div
key={`${p.kind}-${idx}`}
onClick={() => togglePlanRow(idx)}
title="点击勾选/取消该方案(数量用右侧加减调整)"
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '6px 8px',
borderRadius: 6,
cursor: 'pointer',
opacity: p.count === 0 ? 0.45 : 1,
background: p.count === 0 ? 'transparent' : 'rgba(22,119,255,0.03)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Space size={6} wrap>
<Text strong={p.count > 0}>{p.title}</Text>
{p.variant_name && <Tag color="purple">{p.variant_name}</Tag>}
{p.detail && (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: p.detail }}>
{p.detail}
</Text>
)}
{p.prompt_hint && (
<Text type="secondary" style={{ fontSize: 12 }}>🎯 {p.prompt_hint}</Text>
)}
</Space>
</div>
<span onClick={(e) => e.stopPropagation()}>
<InputNumber
size="small"
min={0}
max={5}
value={p.count}
onChange={(v) => setPlanCount(idx, v ?? 0)}
/>
</span>
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0' }}>
<Checkbox
checked={planAllEnabled}
onChange={(e) => togglePlanAll(e.target.checked)}
title="勾选:全部方案至少 1 张(>1 张保留原数量);取消:全部改为 0,方便单独勾选一种方案"
>
</Checkbox>
{planSource === 'ai' && (
<a
style={{ fontSize: 12 }}
onClick={() => {
setPlan(DEFAULT_PLAN.map((p) => ({ ...p })));
setPlanSource('default');
setPlanSummary('');
}}
>
</a>
)}
<Text type="secondary" style={{ fontSize: 12, flex: 1 }} ellipsis={{ tooltip: planSummary }}>
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
</Text>
</div>
{/* 水印设置:左列最下、靠右 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
</Button>
</Popover>
</div>
</Card>
</Col>
<Col span={12}>
<div>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'rgba(0,0,0,0.82)', marginBottom: 6 }}>
</div>
<Segmented
value={styleSet}
onChange={(v) => setStyleSet(v as number)}
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{stylePrompts[styleSet] !== undefined && (
<a
style={{ marginLeft: 8 }}
onClick={() =>
setStylePrompts((p) => {
const n = { ...p };
delete n[styleSet];
return n;
})
}
>
</a>
)}
</Text>
<Input.TextArea
rows={3}
value={currentStylePrompt}
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Input.TextArea
rows={3}
value={requirements}
onChange={(e) => setRequirements(e.target.value)}
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
/>
</div>
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}>
{generating && (
<div style={{ flex: 1, minWidth: 200 }}>
<Progress
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
size={['100%', 10]}
status="active"
format={() => `${doneCount}/${suiteTotal}`}
/>
</div>
)}
<Select
style={{ width: 260 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<Button
type="primary"
disabled={selectedKeys.size === 0 || generating || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</Button>
</Row>
</div>
</Col>
</Row>
</Card>
</div>
{/* ── 生成结果 ── */}
<div id="section-result">
<Card
title="6 生成结果"
extra={
suite && ['done', 'partial'].includes(suite.status) && (
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
ZIP
</Button>
)
}
>
{!suite ? (
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : (
<>
<div style={{ marginBottom: 8 }}>
<Space wrap size={8}>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Tag
color={
suite.status === 'done'
? 'green'
: suite.status === 'failed'
? 'red'
: 'processing'
}
>
{suite.status === 'running'
? '生成中'
: suite.status === 'done'
? '完成'
: suite.status === 'partial'
? '部分失败'
: suite.status === 'pending'
? '排队中'
: '失败'}
</Tag>
<Tag>{suite.ratio} </Tag>
<Tag>{suite.lang}</Tag>
<Tag>{STYLE_SET_OPTIONS.find((s) => s.value === suite.style_set)?.label ?? '自定义'}</Tag>
{suite.total != null && <Tag> {suite.total} </Tag>}
</Space>
</div>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{suite.images
.filter((img) => img.status === 'ok' || img.status === 'failed')
.map((img) => (
<div
key={img.type_id + img.name}
style={{ width: 112, position: 'relative' }}
title={img.error || img.name}
>
{img.status === 'ok' ? (
<Image
src={img.url}
width={100}
height={133}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
) : (
<div
style={{
width: 100,
height: 133,
borderRadius: 6,
background: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ff4d4f',
}}
>
</div>
)}
<div
style={{
fontSize: 11,
color: img.status === 'ok' ? 'rgba(0,0,0,0.45)' : '#ff4d4f',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={img.error || img.name}
>
{img.status === 'failed' && img.error ? img.error : img.name}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
</Card>
</div>
{/* 单张 AI 生图弹窗 */}
<AiImageGenModal
open={genModal !== null}
productId={product.id}
source={genModal}
onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()}
/>
</div>
);
}
+86 -10
View File
@@ -1,7 +1,8 @@
/**
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/预留折扣/汇率
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/预留折扣价
* v2.1 调整:划线价改为「倍数」口径(划线价 = 销售价 × (1 + 倍数%)),替代 v1 的 ÷(1-预留折扣)。
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/划线价倍数/汇率
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/划线价(¥/₽)
*/
export type LogisticsLevel = 'low' | 'high' | 'high2';
@@ -13,7 +14,7 @@ export interface PricingInput {
weightG: number; // 重量 g
dims: { l: number; w: number; h: number }; // cm
tdPrice: number; // 贴单费用 ¥
discountReserve: number; // 预留折扣 %
lineMultiplier: number; // 划线价倍数 %(划线价 = 销售价 × (1 + 倍数/100)
fxRate: number; // 汇率 CNY→RUB
}
@@ -27,8 +28,8 @@ export interface PricingResult {
commission: number; // 展示用平台佣金
totalCost: number; // 完全成本
sellingPriceRub: number; // 销售价 ₽
reservedPriceCny: number;// 预留折扣后 ¥
reservedPriceRub: number;// 预留折扣后
linePriceCny: number; // 划线价 ¥(销售价 × (1 + 倍数))
linePriceRub: number; // 划线价
}
/** 物流费(不含贴单费) */
@@ -44,7 +45,7 @@ export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number
}
export function calculatePricing(input: PricingInput): PricingResult {
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, discountReserve, fxRate } = input;
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, lineMultiplier, fxRate } = input;
const logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
const profitDecimal = profitRate / 100;
@@ -57,8 +58,8 @@ export function calculatePricing(input: PricingInput): PricingResult {
const totalCost = purchasePrice + logisticsFee + fullCommission;
const sellingPriceRub = sellingPriceCny * fxRate;
const reservedPriceCny = sellingPriceCny / (1 - discountReserve / 100);
const reservedPriceRub = reservedPriceCny * fxRate;
const linePriceCny = sellingPriceCny * (1 + lineMultiplier / 100);
const linePriceRub = linePriceCny * fxRate;
return {
logisticsFee,
@@ -70,8 +71,8 @@ export function calculatePricing(input: PricingInput): PricingResult {
commission,
totalCost,
sellingPriceRub,
reservedPriceCny,
reservedPriceRub,
linePriceCny,
linePriceRub,
};
}
@@ -82,3 +83,78 @@ export function validateLogisticsLevel(sellingPriceRub: number, level: Logistics
if (sellingPriceRub >= 135 && sellingPriceRub <= 140) return '汇率波动,建议避开 135~140 ₽ 区间';
return '';
}
// ── 以下为 v2.1 试算页从 v1 web/js/app.js:901-991 移植的校验与规则文案 ──────────
// 注意:这三个函数的销售价单位是 CNY(与 v1 一致),不要与上面的 validateLogisticsLevel(₽)混用。
/** 尺寸硬校验:不合规返回错误文案,合规返回 ''(结果卡应显示 -- 且不落计价结果) */
export function validateDimensions(
weightG: number,
dims: { l: number; w: number; h: number },
level: LogisticsLevel,
): string {
const sides = [dims.l, dims.w, dims.h].sort((a, b) => b - a);
const longestSide = sides[0];
const sumOfSides = sides.reduce((s, v) => s + v, 0);
if (level === 'low') {
if (weightG <= 500) {
if (sumOfSides > 90 || longestSide > 60) {
return (
`低等级物流重量≤500g时,要求三边之和≤90厘米且最长边≤60厘米。当前商品三边之和为` +
`${sumOfSides.toFixed(2)}厘米,最长边为${longestSide.toFixed(2)}厘米,不符合要求。`
);
}
} else if (sumOfSides > 150) {
return `低等级物流重量>500g时,要求三边之和≤150厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
} else if (longestSide > 60) {
return `低等级物流重量>500g时,要求最长边≤60厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
}
} else {
if (weightG <= 2000) {
if (sumOfSides > 150) {
return `高等级物流重量≤2000g时,要求三边之和≤150厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
}
if (longestSide > 60) {
return `高等级物流重量≤2000g时,要求最长边≤60厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
}
} else if (sumOfSides > 250) {
return `高等级物流重量>2000g时,要求三边之和≤250厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
} else if (longestSide > 150) {
return `高等级物流重量>2000g时,要求最长边≤150厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
}
}
return '';
}
/** 物流等级建议(销售价单位 CNY) */
export function validateLogisticsLevelCny(sellingPriceCny: number, level: LogisticsLevel): string {
if (sellingPriceCny > 140 && level === 'low') {
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,超过140元,建议选择高等级物流以提供更好的服务体验。`;
}
if (sellingPriceCny < 135 && level !== 'low') {
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,低于135元,建议选择低等级物流以降低成本。`;
}
return '';
}
/** 价格区间提示(销售价单位 CNY) */
export function validatePriceRange(sellingPriceCny: number): string {
if (sellingPriceCny >= 135 && sellingPriceCny <= 140) {
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,处于135-140元的区间。由于汇率波动,建议尽量避免此价格区间。`;
}
return '';
}
/** 物流费规则文案(结果卡展示,含贴单费) */
export function logisticsFeeRule(weightG: number, level: LogisticsLevel, tdPrice: number): string {
let rule = '';
if (level === 'low') {
rule = weightG <= 500 ? 'low3.12 + 0.026×重量' : 'low23.92 + 0.01768×重量';
} else if (level === 'high2') {
rule = weightG <= 5000 ? '高222.88 + 0.026×重量' : '高264.48 + 0.024×重量';
} else {
rule = weightG <= 2000 ? '普通:16.64 + 0.026×重量' : '普通:37.44 + 0.01768×重量';
}
if (tdPrice > 0) rule += ' + 通递价';
return rule;
}
+2
View File
@@ -4,6 +4,7 @@ import AiImagePage from '@/pages/ai-image';
import CollectionPage from '@/pages/collection/CollectionPage';
import ProductEditPage from '@/pages/product/ProductEditPage';
import ShopsPage from '@/pages/shops/ShopsPage';
import TrialPage from '@/pages/trial/TrialPage';
export const router = createBrowserRouter([
{
@@ -13,6 +14,7 @@ export const router = createBrowserRouter([
{ index: true, element: <Navigate to="/collection" replace /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'product/:id', element: <ProductEditPage /> },
{ path: 'trial/:id', element: <TrialPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'ai-image', element: <AiImagePage /> },
],
+195
View File
@@ -0,0 +1,195 @@
/**
* 套图生成 / 单张 AI 生图 / 图片导出 服务层。
* 契约对齐 docs/v2.1/api.mdPhase A 前端先行;带 ★ 的接口待 Phase B 服务端实现)。
* 常量(类型/风格/模型)与 image-suite-studio 的 src/api/client.ts 保持一致。
*/
import { api } from './api';
import apiClient from './api';
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
export const SUITE_TYPE_OPTIONS = [
{ value: 'white_bg', label: '白底主图' },
{ value: 'key_features', label: '核心卖点图' },
{ value: 'selling_pt', label: '卖点图' },
{ value: 'material', label: '材质图' },
{ value: 'lifestyle', label: '场景展示图' },
{ value: 'multi_scene', label: '多场景拼图' },
{ value: 'ecommerce_detail', label: '电商详情图' },
{ value: 'size_chart', label: '尺寸标注图' },
{ value: 'sku_collection', label: 'SKU合集图' },
{ value: 'custom', label: '创意图' },
] as const;
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
export interface PlanItem {
kind: string;
title: string;
detail: string;
prompt_hint: string;
count: number;
variant_name?: string | null;
}
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) => ({
kind: t.value,
title: t.label,
detail: '',
prompt_hint: '',
count: 1,
variant_name: null,
}));
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [
{ value: 1, label: '北欧极简', prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净' },
{ value: 2, label: '清新明亮', prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净' },
{ value: 3, label: '高级感深色', prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级' },
{ value: 4, label: '暖调生活', prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强' },
{ value: 5, label: '纯净棚拍', prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出' },
] as const;
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
};
/** 生图模型(服务端按模型名路由 provider,同 image-suite-studio */
export const IMAGE_MODEL_OPTIONS = [
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
{ value: 'wan2.7-image-pro', label: 'wan2.7-image-pro', desc: '异步精修,质感与细节更强,适合高质量电商大片' },
{ value: 'wan2.6-image', label: 'wan2.6-image', desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好' },
{ value: 'wan2.6-t2i', label: 'wan2.6-t2i', desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快' },
{ value: 'gpt-image-2', label: 'gpt-image-2', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
{ value: 'nano-banana', label: 'nano-banana', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
{ value: 'nano-banana-2', label: 'nano-banana-2', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
{ value: 'nano-banana-pro', label: 'nano-banana-pro', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
] as const;
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2-vip';
export interface WatermarkPayload {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number;
}
export const DEFAULT_WATERMARK: WatermarkPayload = {
enabled: false,
type: 'text',
text: 'xiongmaoyx',
opacity: 30,
};
/** 传给规划/生成的文本素材(kind 与采集 ScanResult.texts 一致) */
export interface SuiteTextPayload {
kind: string;
content: string;
pairs?: Array<{ key: string; value: string }> | null;
}
export interface SuitePlanPayload {
product_id?: string;
texts: SuiteTextPayload[];
sku_variants: string[];
image_stats: Record<string, number>;
platform: string;
requirements?: string | null;
}
export interface SuiteGeneratePayload {
product_id?: string;
texts: SuiteTextPayload[];
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
style_set: number;
style_prompt?: string | null;
requirements?: string | null;
plan: PlanItem[];
platform: string;
model?: string | null;
watermark?: WatermarkPayload;
}
export interface SuiteImageInfo {
type_id: string;
name: string;
url: string;
status: string;
error?: string | null;
}
export interface SuiteInfo {
id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
style_set: number;
platform: string;
lang: string;
ratio: string;
provider?: string;
total?: number;
images: SuiteImageInfo[];
error?: string | null;
}
/** ★ AI 智能规划出图方案 */
export function planSuite(payload: SuitePlanPayload) {
return api.post<{ summary: string; items: PlanItem[] }>('/suite/plan', payload);
}
/** ★ 提交一键生成任务 */
export function generateSuite(payload: SuiteGeneratePayload) {
return api.post<{ suite_id: string }>('/suite/generate', payload);
}
/** ★ 查询套图任务状态(轮询用) */
export function getSuite(suiteId: string) {
return api.get<SuiteInfo>(`/suites/${suiteId}`);
}
/** ★ 下载生成结果 ZIPblob */
export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
const res = await apiClient.get(`/suites/${suiteId}/zip`, { responseType: 'blob' });
return res.data as Blob;
}
export interface ImageEditSinglePayload {
product_id?: string;
image_url: string;
prompt: string;
model: string;
append?: boolean;
}
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
export function imageEditSingle(payload: ImageEditSinglePayload) {
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
}
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
}
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
export async function exportImages(payload: ExportImagesPayload): Promise<Blob> {
const res = await apiClient.post('/export/images', payload, { responseType: 'blob' });
return res.data as Blob;
}
/** 上传补充参考图到商品素材(已有接口:POST /api/materials/bytes */
export async function uploadProductAsset(
productId: string,
file: File,
groupKey = 'upload',
): Promise<{ asset_id: string; status: string }> {
const form = new FormData();
form.append('product_id', productId);
form.append('group_key', groupKey);
form.append('type', 'img');
form.append('file', file);
const res = await apiClient.post('/materials/bytes', form);
return res.data as { asset_id: string; status: string };
}
+57
View File
@@ -0,0 +1,57 @@
/**
* 文件/CSV 工具:v1 web/js/app.js 的 CSV 导出(BOM + RFC 转义)与文件名清理移植。
*/
/** 文件名清理:去掉路径分隔符与常见非法字符 */
export function cleanFilename(name: string): string {
return (name || '').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim();
}
/** 二进制下载(前端环境没有 chrome.downloads,用 <a download> */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/** CSV 单元格转义:含逗号/引号/换行时加引号并双写引号 */
function escapeCell(v: string | number | null | undefined): string {
const s = v == null ? '' : String(v);
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
/** 组装 CSV 文本(UTF-8 BOM,保证 Excel 中文不乱码) */
export function toCsv(rows: Array<Array<string | number | null | undefined>>): string {
return '\uFEFF' + rows.map((row) => row.map(escapeCell).join(',')).join('\r\n');
}
/** CSV 文本下载 */
export function downloadCsv(rows: Array<Array<string | number | null | undefined>>, filename: string): void {
downloadBlob(new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8' }), cleanFilename(filename) || 'export.csv');
}
/** 复制文本到剪贴板(失败时降级选中文案由调用方提示) */
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
return true;
} catch {
return false;
}
}
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pages/trial/aiimagegenmodal.tsx","./src/pages/trial/trialexportpanel.tsx","./src/pages/trial/trialinfopanel.tsx","./src/pages/trial/trialpage.tsx","./src/pages/trial/trialpricingpanel.tsx","./src/pages/trial/trialsuitepanel.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/services/suite.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/file.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}