4 Commits

Author SHA1 Message Date
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
60 changed files with 12954 additions and 91 deletions
@@ -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']
});
+1
View File
@@ -55,6 +55,7 @@ SYSTEM_PROMPT = """你是一名熟悉俄罗斯消费者表达习惯、Ozon 商
【标签】
1. 输出10~15个标签;每个俄文标签必须是一个独立单词,不是短语,不带 #,不含标点,不把两个词用空格连接。
2. 标签中绝对禁止出现任何特殊符号:禁止 - 连字符/中划线、_ 下划线、/ 斜杠、. 句点、, 逗号等一切非字母字符(俄文/英文字母之外的字符一律不允许);需要组合概念时用去掉连字符的合成词写法(例如"новогоднийподарок"式复合词或改用两个独立标签)。
2. 标签优先覆盖品类、造型、材质、功能、风格、摆放场景等高相关搜索概念,避免同词不同变格反复出现。
3. 中文标签也尽量为一个词;tags_ru 与 tags_zh 必须逐项语义对应。
+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"}