From efe3474deb8572b1dc1c28124753f7da7eaaba2e Mon Sep 17 00:00:00 2001 From: R524809 Date: Thu, 27 Aug 2026 17:18:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=87=E9=9B=86=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=95=B4=E5=90=88=E8=BF=9BOSK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_0eb18e04-343a-4e41-a73a-97fcfd3d696b.md | 51 + README.md | 75 +- docs/v2.1/README.md | 10 +- docs/v2.1/collect.md | 131 +- .../collector/entrypoints/background.ts | 66 + .../collector/entrypoints/bridge.content.ts | 36 + .../collector/entrypoints/content/index.ts | 26 + .../collector/entrypoints/panel.content.ts | 182 + .../collector/entrypoints/sidepanel/App.tsx | 1218 +++++ .../entrypoints/sidepanel/index.html | 334 ++ extensions/collector/package.json | 25 + extensions/collector/pnpm-lock.yaml | 4411 +++++++++++++++++ extensions/collector/pnpm-workspace.yaml | 3 + extensions/collector/scripts/verify-1688.mjs | 71 + extensions/collector/src/api/client.ts | 302 ++ extensions/collector/src/api/report.ts | 46 + .../collector/src/bridge/read-window.ts | 43 + .../collector/src/collector/1688-state.ts | 168 + extensions/collector/src/collector/dom.ts | 88 + extensions/collector/src/collector/image.ts | 146 + extensions/collector/src/collector/jsonld.ts | 107 + extensions/collector/src/collector/merge.ts | 100 + .../collector/src/collector/ozon-api.ts | 283 ++ .../collector/src/collector/ozon-state.ts | 244 + .../collector/src/collector/platforms/1688.ts | 95 + .../collector/src/collector/platforms/ozon.ts | 160 + .../src/collector/platforms/taobao.ts | 49 + extensions/collector/src/collector/scan.ts | 63 + .../collector/src/collector/ssr-builder.ts | 157 + extensions/collector/src/collector/ssr.ts | 79 + .../collector/src/collector/taobao-state.ts | 115 + extensions/collector/src/collector/text.ts | 131 + extensions/collector/src/collector/url.ts | 132 + extensions/collector/src/profiles/1688.ts | 139 + extensions/collector/src/profiles/index.ts | 21 + extensions/collector/src/profiles/ozon.ts | 162 + extensions/collector/src/profiles/taobao.ts | 143 + extensions/collector/src/profiles/types.ts | 79 + extensions/collector/src/storage/settings.ts | 77 + extensions/collector/tsconfig.json | 11 + extensions/collector/wxt.config.ts | 53 + 41 files changed, 9704 insertions(+), 128 deletions(-) create mode 100644 .zcode/plans/plan-sess_0eb18e04-343a-4e41-a73a-97fcfd3d696b.md create mode 100644 extensions/collector/entrypoints/background.ts create mode 100644 extensions/collector/entrypoints/bridge.content.ts create mode 100644 extensions/collector/entrypoints/content/index.ts create mode 100644 extensions/collector/entrypoints/panel.content.ts create mode 100644 extensions/collector/entrypoints/sidepanel/App.tsx create mode 100644 extensions/collector/entrypoints/sidepanel/index.html create mode 100644 extensions/collector/package.json create mode 100644 extensions/collector/pnpm-lock.yaml create mode 100644 extensions/collector/pnpm-workspace.yaml create mode 100644 extensions/collector/scripts/verify-1688.mjs create mode 100644 extensions/collector/src/api/client.ts create mode 100644 extensions/collector/src/api/report.ts create mode 100644 extensions/collector/src/bridge/read-window.ts create mode 100644 extensions/collector/src/collector/1688-state.ts create mode 100644 extensions/collector/src/collector/dom.ts create mode 100644 extensions/collector/src/collector/image.ts create mode 100644 extensions/collector/src/collector/jsonld.ts create mode 100644 extensions/collector/src/collector/merge.ts create mode 100644 extensions/collector/src/collector/ozon-api.ts create mode 100644 extensions/collector/src/collector/ozon-state.ts create mode 100644 extensions/collector/src/collector/platforms/1688.ts create mode 100644 extensions/collector/src/collector/platforms/ozon.ts create mode 100644 extensions/collector/src/collector/platforms/taobao.ts create mode 100644 extensions/collector/src/collector/scan.ts create mode 100644 extensions/collector/src/collector/ssr-builder.ts create mode 100644 extensions/collector/src/collector/ssr.ts create mode 100644 extensions/collector/src/collector/taobao-state.ts create mode 100644 extensions/collector/src/collector/text.ts create mode 100644 extensions/collector/src/collector/url.ts create mode 100644 extensions/collector/src/profiles/1688.ts create mode 100644 extensions/collector/src/profiles/index.ts create mode 100644 extensions/collector/src/profiles/ozon.ts create mode 100644 extensions/collector/src/profiles/taobao.ts create mode 100644 extensions/collector/src/profiles/types.ts create mode 100644 extensions/collector/src/storage/settings.ts create mode 100644 extensions/collector/tsconfig.json create mode 100644 extensions/collector/wxt.config.ts diff --git a/.zcode/plans/plan-sess_0eb18e04-343a-4e41-a73a-97fcfd3d696b.md b/.zcode/plans/plan-sess_0eb18e04-343a-4e41-a73a-97fcfd3d696b.md new file mode 100644 index 0000000..eb592de --- /dev/null +++ b/.zcode/plans/plan-sess_0eb18e04-343a-4e41-a73a-97fcfd3d696b.md @@ -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`**: + - settingsPopup(L615-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 鉴权范围配置 \ No newline at end of file diff --git a/README.md b/README.md index 91e0551..10009f1 100644 --- a/README.md +++ b/README.md @@ -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/` | ④ FastAPI:AI 文案、图生图(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/ # ④ FastAPI(main.py / api / services / schemas / config) -├── web/ # ① 工具台 v1,冻结 -├── extension/ # ② 采集插件(待建) -├── studio/ # ③ 发布工作台(React + Vite + antd,AI 图生图已上线) -├── packages/schema/ # 跨端共享契约(待建) -├── docs/ # 全部文档 +├── server/ # FastAPI(main.py / api / services / schemas / config) +├── extensions/collector/ # Chrome 插件(WXT + React,pnpm -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 整理(远期重启直传时用) | diff --git a/docs/v2.1/README.md b/docs/v2.1/README.md index 63baf0f..dcbdf5f 100644 --- a/docs/v2.1/README.md +++ b/docs/v2.1/README.md @@ -58,7 +58,7 @@ V2(原计划) V2.1(现在) | 编号 | 决策 | 内容 | 理由 | |---|---|---|---| | **D1** | 放弃 Ozon API 直传 | `server/api/publish.py`、`services/publish.py`、店铺/类目代理等发布链路代码**保留但冻结**,不作为主链路;上品方式 = CSV 导出 + 人工后台录入 | ImportProductsV3 字段对齐工作量对个人不可承受;人工上品每品只花几分钟 | -| **D2** | 采集以 image-suite-studio 为主要参考 | 采集引擎(collector/profiles/平台文件)、页内悬浮面板 UI、三平台覆盖(Ozon/1688/淘宝天猫)全部以 `../image-suite-studio/extension` 的实现为准 | 该项目已跑通三平台商详页采集(Ozon 四路径降级 + 阿里系 MAIN-world 桥),且与本仓 extension-v2 同源,合并成本低 | +| **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 验证;服务端代码整体平移复用 | @@ -118,10 +118,10 @@ V2(原计划) V2.1(现在) | 阶段 | 内容 | 状态 | |---|---|---| -| **Phase A(本次)** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 本次交付 | -| **Phase B** | 服务端:`/api/suite/*`、`/api/suites/*`、`/api/export/images`、`/api/proxy-image`(从 image-suite-studio/server 平移 generator/planner/prompts/watermark/tasks/storage) | 待开工 | -| **Phase C** | 插件:extension-v2 的 collector/profiles/面板对齐 image-suite-studio(补 1688/淘宝、页内悬浮面板),采集成功后自动打开 `/trial/{id}` | 待开工 | -| **Phase D** | 打磨:批量 CSV 服务端化、生成图自动回写素材、水印增强(拖动定位/白底处理评估移植 web v1 能力)、组合码批量导出优化 | 待开工 | +| **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 结束时,试算页的计价/文案/素材上传/入库立即可用(对接已有接口),套图相关按钮点击会提示「服务端接口未实现」。 diff --git a/docs/v2.1/collect.md b/docs/v2.1/collect.md index b53f66d..918cfeb 100644 --- a/docs/v2.1/collect.md +++ b/docs/v2.1/collect.md @@ -1,109 +1,74 @@ -# V2.1 采集方案(以 image-suite-studio 为主要参考) +# V2.1 采集方案(扩展并入主仓 + 上报开关) -> 状态:方案定稿(Phase C 实施) +> 状态:已实施(Phase C 完成) > 参考项目:`/Users/joey-xd/sites/seller-store/image-suite-studio`(下称 ISS) -> 原则:**采集功能以 ISS 的实现为准**。ISS 的采集引擎本就与本仓 extension-v1/v2 同源,V2.1 把 ISS 的最新版采集实现(含 1688/淘宝与页内悬浮面板)合并回本仓,并接上本仓已有的服务端上传链路。 +> 架构(方案三,D2 决策修订):**ISS 扩展整体并入主仓 `extensions/collector/`**,成为唯一采集工具 +> (extension-v2 原地冻结、不再维护);扩展新增「上报商品」功能,采集后一键入库主仓后台并自动打开商品试算页。 +> 「ISS 独立产品化」远期通过构建/配置裁剪实现(扩展天然独立分发;server 的 suite API 模块不依赖 products), +> 不在代码层分叉——这是「单仓维护」与「独立产品可能」的折中。 --- -## 1. 采集形态与架构(照搬 ISS) - -Chrome MV3 扩展(WXT + React + antd),四个 content script 注入三个平台的四个站点: - -| 站点 | 匹配 | -|---|---| -| Ozon | `*.ozon.ru` / `*.ozon.kz` / `*.ozon.by` | -| 1688 | `detail.1688.com` | -| 淘宝/天猫 | `item.taobao.com` / `detail.tmall.com` | +## 1. 目录与形态 ``` -extension/ -├── entrypoints/ -│ ├── background.ts # Service Worker,唯一出网口(上传/代理 fetch) -│ ├── content/index.ts # 采集脚本(isolated world),挂 window.__SuiteCollector -│ ├── bridge.content.ts # MAIN world 桥:postMessage 读页面全局变量(window.context / __ICE_APP_CONTEXT__) -│ └── panel.content.ts # 页内悬浮按钮「套」+ 右侧滑出面板(Shadow DOM + iframe 懒加载) -├── src/ -│ ├── collector/ # 采集引擎(见 §2) -│ ├── profiles/ # 声明式站点配置(ozon/1688/taobao) -│ ├── api/client.ts # 上传 payload 组装 -│ └── storage/settings.ts# baseUrl/token/水印设置 -└── entrypoints/sidepanel/ # 面板 UI(App.tsx,iframe 复用) +ozon-seller-kit/ +├── extensions/ # Chrome 插件集合(复数,容纳未来多个插件) +│ └── collector/ ← ISS 扩展并入版(三平台采集 + 套图生图面板 + 商品上报) +├── extension-v1/ # 冻结(旧 1688/淘宝) +├── extension-v2/ # 冻结(旧 Ozon 采集,被 collector 取代) +├── server/ # 唯一后端(8800) +└── studio/ # 前端(试算页等) ``` -UI 交互形态采用 ISS 的**页内悬浮面板**(在商品页右侧滑出,iframe 加载 sidepanel.html), -比 Chrome 原生 Side Panel 更贴近「边看商品边采集」的操作。 +- 构建:`pnpm -C extensions/collector build` → `.output/chrome-mv3`(Chrome 开发者模式加载 unpacked) +- 扩展包名:`ozon-collector-extension`;manifest 名保留「电商套图工作台」 +- host_permissions 已加 `127.0.0.1:8800`(上报)与 `8900`(试算页) -## 2. 采集引擎(多源合并,主路径优先、DOM 兜底) +## 2. 采集引擎(与 ISS 完全一致,见下表) -统一入口 `src/collector/scan.ts`:`scanCurrentPage()` → `matchProfile(location.href)` → 分发平台文件 → `merge.ts` 装配 `ScanResult`。 +| 平台 | 主路径 | 兜底/补充 | +|---|---|---| +| **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`(deepFind;skuProps 全维展开 + skuMapOriginal + 尺寸重量参数) | ② DOM(`#productAttributes` cells、`#detail`) | -| 平台 | 主路径 | 兜底/补充 | 关键实现(ISS 路径) | -|---|---|---|---| -| **Ozon** | ① SSR `data-state` widget 白名单(webGallery/webPrice/webProductHeading/webShort\|Detailed\|Characteristics/webAspects/webReviewProductScore/breadCrumbs) | ② JSON-LD ③ 站内 `entrypoint-api.bx` JSON(两 URL 合并:默认页 + `?layout_page_index=2` 描述页)④ DOM 选择器 + 模拟人工滚动触发懒加载 | `collector/ozon-state.ts`、`jsonld.ts`、`ozon-api.ts`、`platforms/ozon.ts` | -| **淘宝/天猫** | ① MAIN world 桥读 `__ICE_APP_CONTEXT__`(loaderData…item/skuBase/priceVO/参数表;SKU 缺失从 `__general_skupanel_cache_data` 容错深搜;**不调 mtop 签名接口防风控**) | ② DOM(CSS Modules 前缀匹配 `[class*="mainTitle--"]`;详情图 `excludeWithin` 排除评价/推荐;**desc 故意不采**) | `collector/taobao-state.ts`、`ssr.ts`、`platforms/taobao.ts` | -| **1688** | ① MAIN world 桥读 `window.context`(deepFind BFS:gallery/tempModel/skuModel.skuProps 全维展开 + skuMapOriginal 价格库存 + pieceWeightScaleInfo 自动生成尺寸重量参数/productAttributes) | ② DOM(`#productAttributes` cells 模式参数表、`#detail` 详情图) | `collector/1688-state.ts`、`platforms/1688.ts` | +采集数据结构 ScanResult:`{ platform, itemId, url, texts[], images[], breadcrumbs?, stats, warnings, source }`(texts: kind/content/pairs;images: groupKey/groupName/variantName/url/index/type)。 -通用提取器(照搬):`text.ts`(join/first/table/cells 四模式)、`image.ts`(srcProps 降级链 + srcset 挑最大 + Shadow DOM 穿透 + SKU 按 `url::规格名` 去重)、`url.ts`(原图还原:Ozon `/wc\d+/`、阿里 `xxx.jpg_400x400.jpg → xxx.jpg`;`toThumbUrl` 反向缩略图)、`merge.ts`(primary 先入 fallback 填缺,按 dedupeKey 去重,重排 main→sku→detail→video)。 - -### 采集数据结构(ScanResult,与 ISS 完全一致) - -```jsonc -{ - "platform": "ozon | 1688 | taobao", - "itemId": "…|null", "url": "…", "scannedAt": 1724000000000, - "source": "state | ssr | jsonld | api | dom | mixed", - "breadcrumbs": [ { "name": "…", "href": "…", "searchCategoryId": 7041 } ], // 仅 Ozon - "stats": { "main": 8, "sku": 6, "detail": 12, "video": 1 }, - "warnings": ["详情图为 0 张,请滚动到页面底部后重新采集"], - "texts": [ { "kind": "title|price|params|selling_point|desc|brand|sales|shop", - "content": "…", "pairs": [ { "key": "材质", "value": "PP塑料" } ] } ], - "images": [ { "key": "main-001", "groupKey": "main", "groupName": "主图", - "variantName": "粉色", "url": "原图URL", "thumbUrl": "缩略图URL", - "index": 0, "type": "img|video", "width": 800, "height": 800 } ] -} -``` - -## 3. 采集后双动作(V2.1 新增) - -ISS 采集完是「本地展示 + 生图」,本仓在其后追加落库与跳转: +## 3. 商品上报(v2.1 新增) ``` -快速采集(悬浮面板) - │ scanCurrentPage() → ScanResult +快速采集(页内悬浮面板 / Side Panel) + │ scanCurrentPage() → ScanResult(面板内可编辑标题/尺寸/重量/描述) ▼ -① 同步到采集箱:POST /api/materials(本仓已实现) - │ payload = { platform, item_id, url, texts, images: [{url, group_key, variant_name, dedupe_key, width, height, type}] } - │ 响应 { product_id, created } ← 服务端已有返回,插件此前未用,Phase C 起使用 +「上报商品」按钮(01 商品信息区块下方;设置里可开关) + │ POST {reportBaseUrl}/api/materials + │ payload = { source: {platform, itemId, url, collectedAt}, + │ texts: editedTexts(), // 编辑后文本 + │ images: 全量采集图(groupKey/groupName/variantName/url/index/type), + │ refererOrigin: 源页 origin } // 服务端下载源图带 Referer ▼ -② 自动打开商品试算页:chrome.tabs.create({ url: `${studioBaseUrl}/trial/${product_id}` }) - │ studioBaseUrl 存插件设置(默认 http://localhost:8900) - ▼ - 面板内提示「已入库并打开试算页」,用户可直接在试算页继续流水线 +响应 { product_id } → chrome.tabs.create 打开 {studioBaseUrl}/trial/{product_id} ``` 要点: -- `POST /api/materials` 的 texts/images 契约已对齐 ScanResult(extension-v2 现有 `buildMaterialsPayload` 可直接复用)。 -- 重复采集同一商品:服务端按 `platform + item_id` 复用 Product(已实现),插件拿到同一 `product_id`,打开试算页即回到原商品。 -- 1688/淘宝素材上传:`_apply_texts` 平台无关,参数表/重量/尺寸解析已兼容(`_apply_weight_dims` 的 kg→g、cm→mm 换算同样适用 1688 的 `pieceWeightScaleInfo`)。 +- **契约**:主仓 `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. extension-v2 改造清单(Phase C) +## 4. 面板能力(保持 ISS 原样) -| # | 改动 | 说明 | -|---|---|---| -| 1 | 用 ISS 的 `src/collector`、`src/profiles`、`src/bridge` 整体替换 extension-v2 对应目录 | ISS 版本更新(含 1688/淘宝 + 多项修复),以 ISS 为准 | -| 2 | 新增 `panel.content.ts` 页内悬浮面板入口 | 保留原生 Side Panel 入口,两种方式共存 | -| 3 | 面板 UI(sidepanel/App.tsx)以 ISS 版为基线,去掉「出图方案/生成结果」区块 | 生图移到试算页做(屏幕更大、可对照商品信息);面板保留:采集结果核对/编辑、勾选图片、上传、下载采集图 | -| 4 | 上传成功后读响应 `product_id`,`chrome.tabs.create` 打开 `{studio}/trial/{product_id}` | 设置项 `studioBaseUrl` | -| 5 | background 保持唯一出网口(代理图片 fetch 绕防盗链 + 上传) | 已是现状 | -| 6 | `GET /api/collected?platform=&itemId=` 采集前查重提示 | 已有接口,面板显示「已采集过,将打开原商品」 | +快速采集、商品信息编辑、图片勾选/上传/下载、AI 智能规划、风格/生图要求/模型选择、一键生图、生成结果导出——全部保留,仍连 ISS 生图 server(`baseUrl`,默认 3300)。 +后续 Phase B 把生图服务端平移进主仓后,`baseUrl` 与 `reportBaseUrl` 可统一指向主仓。 ## 5. 与 ISS 的差异总表 -| 维度 | ISS | 本仓 V2.1 | +| 维度 | ISS(独立项目) | 本仓 extensions/collector | |---|---|---| -| 采集引擎 | 三平台完整版 | **相同**(Phase C 平移) | -| 采集结果去向 | 仅面板展示(无持久化) | 落库 products/product_assets(复用 V2 已实现) | -| 生图入口 | 面板内 03/04 区块 | 移到 studio 试算页(功能对齐 + 单张 AI 生图增强) | -| 生图后端 | 独立 server(3300 端口,无鉴权) | 并入本仓 server(8800,JWT;Phase B 平移) | -| 采集后动作 | 无 | 入库 + 自动打开 `/trial/{id}` | +| 采集引擎 | 三平台完整版 | 相同(整体并入) | +| 生图面板 | 连 ISS server(3300) | 同左(Phase B 后可切主仓) | +| 商品入库 | 无 | 「上报商品」→ 主仓 /api/materials + 自动打开试算页 | +| 鉴权 | 无 | 本地宽松模式(远期收紧) | +| 独立产品化 | 本体 | 远期:构建/配置裁剪出 collector + suite API 即可独立 | diff --git a/extensions/collector/entrypoints/background.ts b/extensions/collector/entrypoints/background.ts new file mode 100644 index 0000000..4636908 --- /dev/null +++ b/extensions/collector/entrypoints/background.ts @@ -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 = {}; + 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; + }); +}); diff --git a/extensions/collector/entrypoints/bridge.content.ts b/extensions/collector/entrypoints/bridge.content.ts new file mode 100644 index 0000000..4be3522 --- /dev/null +++ b/extensions/collector/entrypoints/bridge.content.ts @@ -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 = {}; + 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)[k]; + if (v !== undefined) payload[k] = JSON.parse(JSON.stringify(v)); // 过滤函数/循环引用 + } catch { /* 不可序列化的跳过 */ } + } + window.postMessage({ type: 'sc-bridge-res', requestId: d.requestId, payload }, '*'); + }); + }, +}); diff --git a/extensions/collector/entrypoints/content/index.ts b/extensions/collector/entrypoints/content/index.ts new file mode 100644 index 0000000..325bc7e --- /dev/null +++ b/extensions/collector/entrypoints/content/index.ts @@ -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()'); + }, +}); diff --git a/extensions/collector/entrypoints/panel.content.ts b/extensions/collector/entrypoints/panel.content.ts new file mode 100644 index 0000000..5fde150 --- /dev/null +++ b/extensions/collector/entrypoints/panel.content.ts @@ -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 = + '' + + ''; + 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; +} diff --git a/extensions/collector/entrypoints/sidepanel/App.tsx b/extensions/collector/entrypoints/sidepanel/App.tsx new file mode 100644 index 0000000..20dbdbf --- /dev/null +++ b/extensions/collector/entrypoints/sidepanel/App.tsx @@ -0,0 +1,1218 @@ +/** + * 电商套图工作台 - Side Panel + * + * 布局:顶栏 + 目标平台 + * ├─ 左 01 商品信息 | 右 02 采集图片(两列等高,采集阶段) + * ├─ 03 出图方案(整行:方案列表 + AI 智能规划 + 风格 + 一键生成) + * └─ 04 生成结果(整行) + * + * 流程:采集页面 → 编辑/勾选 → AI 规划或用默认方案 → 一键生成 → 导出 ZIP + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd'; +import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons'; +import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; +import { cleanFilename } from '../../src/collector/url'; +import { + buildGeneratePayload, downloadSuiteZip, uploadImage, exportImages, + DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, + type PlanItem, type SuiteInfo, +} from '../../src/api/client'; +import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; + +/** 数据来源 → 展示含义 */ +const SOURCE_LABELS: Record = { + state: '页面数据', + ssr: '页面数据', + jsonld: '结构化数据', + api: '站内接口', + dom: 'DOM解析', + mixed: '混合来源', +}; + +/** 平台 → 展示名 */ +const PLATFORM_LABELS: Record = { + ozon: 'Ozon', + '1688': '1688', + taobao: '淘宝/天猫', +}; + +/** 支持的站点(用于判断刷新是否有意义) */ +const SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i; + +/** 页内悬浮面板模式(作为 iframe 嵌在商品页里运行);独立 Side Panel 打开时为 false */ +const IN_PAGE = window.self !== window.top; + +/** 通知宿主页收起悬浮面板 */ +function closePanel(): void { + window.parent.postMessage({ type: 'sc-panel-close' }, '*'); +} + +/** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */ +async function tryScan(tabId: number): Promise { + const [res] = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => (window as any).__SuiteCollector?.scan?.() ?? null, + }); + return (res?.result ?? null) as ScanResult | null; +} + +/** 等待 tab 加载完成(需在 reload 前注册监听),超时放行 */ +function waitForTabComplete(tabId: number, timeoutMs = 30_000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs); + function listener(id: number, info: chrome.tabs.TabChangeInfo) { + if (id === tabId && info.status === 'complete') { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + } + chrome.tabs.onUpdated.addListener(listener); + }); +} + +function send(action: string, payload: Record): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action, ...payload }, (res: { ok: boolean; data?: T; error?: string }) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + if (!res?.ok) return reject(new Error(res?.error || '后台请求失败')); + resolve(res.data as T); + }); + }); +} + +// ── 小组件 ───────────────────────────────────────────────────────────────── + +const Section: React.FC<{ + no: string; title: string; extra?: React.ReactNode; children: React.ReactNode; className?: string; +}> = + ({ no, title, extra, children, className }) => ( +
+
+ {no} + {title} + {extra && {extra}} +
+ {children} +
+ ); + +const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ + {children} +
+); + +/** 数量加减器 */ +const Stepper: React.FC<{ value: number; onChange: (v: number) => void }> = ({ value, onChange }) => ( + + + {value} + + +); + +const App: React.FC = () => { + const { modal } = AntApp.useApp(); + + // 采集 + const [scanning, setScanning] = useState(false); + const [retrying, setRetrying] = useState(false); + const [reporting, setReporting] = useState(false); + const [result, setResult] = useState(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [titleEdit, setTitleEdit] = useState(''); + const [priceEdit, setPriceEdit] = useState(''); + const [dimsEdit, setDimsEdit] = useState<{ l: string; w: string; h: string }>({ l: '', w: '', h: '' }); + const [weightEdit, setWeightEdit] = useState(''); + const [descEdit, setDescEdit] = useState(''); + const [paramsOpen, setParamsOpen] = useState(false); + + // 手动上传图片(补充参考图,独立「upload」分组) + const [uploadedImages, setUploadedImages] = useState([]); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + const uploadSeqRef = useRef(0); + + // 服务端 + const [settings, setSettings] = useState({ + baseUrl: 'http://127.0.0.1:3300', + token: '', + watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 }, + reportEnabled: true, + reportBaseUrl: 'http://127.0.0.1:8800', + studioBaseUrl: 'http://localhost:8900', + }); + + // 出图方案 + const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon'); + const [styleSet, setStyleSet] = useState(1); + /** 生图模型(默认 gpt-image-2-vip,走 RightAPI provider) */ + const [model, setModel] = useState('gpt-image-2-vip'); + /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ + const [stylePrompts, setStylePrompts] = useState>({}); + /** 生图要求(最高优先级,强制约束,覆盖其他设定) */ + const [requirements, setRequirements] = useState(''); + const [plan, setPlan] = useState(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 [suite, setSuite] = useState(null); + const [generating, setGenerating] = useState(false); + // 导出采集图片 + const [exporting, setExporting] = useState(false); + // 导出生成结果 ZIP + const [exportingZip, setExportingZip] = useState(false); + const pollRef = useRef | null>(null); + /** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */ + const planSeqRef = useRef(0); + + // 图片放大预览(画廊:可左右切换) + const [preview, setPreview] = useState<{ list: string[]; index: number } | null>(null); + + const openPreview = (list: string[], index: number) => { + if (list.length === 0) return; + setPreview({ list, index: Math.max(0, Math.min(index, list.length - 1)) }); + }; + const previewPrev = () => setPreview(p => p && { ...p, index: (p.index - 1 + p.list.length) % p.list.length }); + const previewNext = () => setPreview(p => p && { ...p, index: (p.index + 1) % p.list.length }); + + useEffect(() => { + loadSettings().then(setSettings); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, []); + + // 预览态的键盘导航:← → 切换,Esc 关闭 + useEffect(() => { + if (!preview) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') previewPrev(); + else if (e.key === 'ArrowRight') previewNext(); + else if (e.key === 'Escape') setPreview(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [preview]); + + // 页内面板模式:Esc 收起面板(预览打开时优先关预览,由上面的监听处理) + useEffect(() => { + if (!IN_PAGE) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !preview) closePanel(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [preview]); + + const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? ''; + const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? ''; + const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? ''; + const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? []; + + const handleScan = () => { + // 生成中:二次确认(后台任务会继续完成,但本面板停止跟踪) + if (generating) { + modal.confirm({ + title: '有正在生成的任务', + content: '重新采集将清空当前采集结果与出图方案,并停止在本面板跟踪正在生成的任务(已提交的后台生成会继续完成,但此处将看不到进度和导出入口)。确定要重新采集吗?', + okText: '继续采集', cancelText: '取消', + onOk: () => doScan(), + }); + return; + } + doScan(); + }; + + const doScan = async () => { + // 规划中:直接重置规划并继续采集(递增序号,迟到的规划响应会被丢弃) + if (planning) { + planSeqRef.current++; + setPlanning(false); + } + // 确认路径下停止对旧生成任务的本地跟踪(后台任务继续,不受影响) + if (generating) { + stopPolling(); + setGenerating(false); + } + setScanning(true); + setRetrying(false); + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) throw new Error('未找到活动标签页'); + + // 首次尝试 + let r = await tryScan(tab.id); + + // 失败:支持站点上大概率是内容脚本未就绪 → 自动刷新重试一次(仅一次,防死循环) + if (!r) { + if (!SUPPORTED_URL_RE.test(tab.url ?? '')) { + throw new Error('当前页面不是支持的商品详情页(支持 Ozon / 1688 / 淘宝 / 天猫)'); + } + setRetrying(true); + const waitLoaded = waitForTabComplete(tab.id); // 先注册监听再刷新,避免错过事件 + await chrome.tabs.reload(tab.id); + await waitLoaded; + await new Promise(res => setTimeout(res, 1500)); // 给内容脚本注入留时间 + r = await tryScan(tab.id); + if (!r) { + throw new Error('采集失败:已自动刷新重试仍无法采集,请确认当前页面是商品详情页后手动重试'); + } + } + + setResult(r); + setSuite(null); + setParamsOpen(false); + // 默认全选主图 + SKU 图(SKU 图带规格名,AI 规划的 variant 绑定要用) + const keys = new Set(r.images.filter(i => i.groupKey === 'main' || i.groupKey === 'sku').map(i => i.key)); + setSelectedKeys(keys); + setTitleEdit(r.texts.find(t => t.kind === 'title')?.content ?? ''); + setDescEdit(r.texts.find(t => t.kind === 'desc')?.content ?? ''); + setPriceEdit(r.texts.find(t => t.kind === 'price')?.content ?? ''); + // 从参数表初始化尺寸/重量(编辑后随生成/规划请求覆盖回参数) + const pairs = r.texts.find(t => t.kind === 'params')?.pairs ?? []; + const dimPair = pairs.find(p => /尺寸|长宽高/i.test(p.key) && (String(p.value).match(/\d+(\.\d+)?/g) ?? []).length >= 3); + const nums = dimPair ? (String(dimPair.value).match(/\d+(\.\d+)?/g) ?? []) : []; + setDimsEdit(nums.length >= 3 ? { l: nums[0], w: nums[1], h: nums[2] } : { l: '', w: '', h: '' }); + const weightPair = pairs.find(p => /重量/i.test(p.key)); + setWeightEdit(weightPair ? String(weightPair.value) : ''); + } catch (e) { + modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } finally { + setScanning(false); + setRetrying(false); + } + }; + + const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; + + /** 上报商品:采集结果 POST 到 ozon-seller-kit 后台,成功后新标签打开商品试算页 */ + const handleReport = async () => { + if (!result) return; + if (!settings.reportEnabled) { + modal.warning({ title: '未启用商品上报', content: '请在「服务端设置」中开启「启用商品上报」。', okText: '知道了' }); + return; + } + setReporting(true); + try { + const data = await send<{ product_id: string }>('reportProduct', { + reportBaseUrl: settings.reportBaseUrl, + payload: { + source: { + platform: result.platform, + itemId: result.itemId, + url: result.url, + collectedAt: result.scannedAt, + }, + texts: editedTexts(), + // 全量上报采集图(main/sku/detail/video),试算页需要完整素材 + images: result.images.map(img => ({ + groupKey: img.groupKey, + groupName: img.groupName, + variantName: img.variantName ?? null, + url: img.url, + index: img.index, + type: img.type, + })), + refererOrigin: result.url ? new URL(result.url).origin : undefined, + }, + }); + const trialUrl = `${settings.studioBaseUrl.replace(/\/$/, '')}/trial/${data.product_id}`; + await chrome.tabs.create({ url: trialUrl, active: true }); + modal.success({ + title: '上报成功', + content: '已入库并在新标签打开商品试算页。', + okText: '知道了', + }); + } catch (e) { + modal.error({ + title: '上报失败', + content: `${e instanceof Error ? e.message : String(e)}(请确认 OSK 后台已启动:${settings.reportBaseUrl})`, + okText: '知道了', + }); + } finally { + setReporting(false); + } + }; + + const pollSuite = useCallback((suiteId: string) => { + stopPolling(); + const startedAt = Date.now(); + let failCount = 0; // 连续轮询失败计数(容忍瞬时抖动,连续 3 次才判定后端不可达) + let timeoutWarned = false; + pollRef.current = setInterval(async () => { + try { + const s = await send('getSuite', { baseUrl: settings.baseUrl, token: settings.token, 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; + modal.warning({ + title: '任务耗时异常', + content: '生成耗时远超预期,任务可能已中断。可稍等片刻观察,或刷新页面后重新生成。', + okText: '知道了', + }); + } + if (['done', 'partial', 'failed'].includes(s.status)) { + stopPolling(); + setGenerating(false); + if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: s.error || '可重试或更换风格重新生成', okText: '知道了' }); + if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' }); + } + } catch (e) { + failCount += 1; + if (failCount >= 3) { + stopPolling(); + setGenerating(false); + modal.error({ + title: '无法连接后端', + content: '连续查询生成进度失败,已停止跟踪。若刚重启过后端(./start.command),任务已被中断,请重新生成。', + okText: '知道了', + }); + } + } + }, 3000); + }, [settings.baseUrl, settings.token, modal]); + + /** 当前编辑后的文本素材(规划与生成共用;尺寸/重量编辑值覆盖回参数表) */ + const editedTexts = () => { + if (!result) return []; + const orig = (kind: string) => result.texts.find(t => t.kind === kind); + let pairs = [...(orig('params')?.pairs ?? [])]; + + // 尺寸/重量覆盖:先移除旧的同义项,再写入编辑值(有值才写) + const dropRe = [/尺寸|长宽高/i, /重量/i]; + pairs = pairs.filter(p => !dropRe.some(re => re.test(p.key))); + const { l, w, h } = dimsEdit; + if (l || w || h) pairs.push({ key: '产品尺寸', value: `${l || '?'}×${w || '?'}×${h || '?'}` }); + if (weightEdit) pairs.push({ key: '重量', value: weightEdit }); + + const texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }> = []; + const title = titleEdit || orig('title')?.content || ''; + const desc = descEdit || orig('desc')?.content || ''; + if (title) texts.push({ kind: 'title', content: title }); + if (priceEdit || orig('price')?.content) texts.push({ kind: 'price', content: priceEdit || orig('price')!.content }); + if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content }); + if (orig('sales')?.content) texts.push({ kind: 'sales', content: orig('sales')!.content }); + if (orig('shop')?.content) texts.push({ kind: 'shop', content: orig('shop')!.content }); + if (pairs.length) texts.push({ kind: 'params', content: '', pairs }); + if (orig('selling_point')?.content) texts.push({ kind: 'selling_point', content: orig('selling_point')!.content }); + if (desc) texts.push({ kind: 'desc', content: desc }); + return texts; + }; + + /** 提交生成任务(planOverride:刚规划出来的方案,避免闭包读到旧 state) */ + const startGenerate = async (planOverride?: PlanItem[]): Promise => { + if (!result) return; + if (selectedKeys.size === 0) { + modal.warning({ title: '请先在采集图片区勾选参考图' }); + return; + } + const activePlan = (planOverride ?? plan).filter(p => p.count > 0); + if (activePlan.length === 0) { + modal.warning({ title: '出图方案的张数都是 0' }); + return; + } + setGenerating(true); + setSuite(null); + try { + const payload = buildGeneratePayload( + allImages, selectedKeys, editedTexts(), + { style_set: styleSet, style_prompt: currentStylePrompt, requirements: requirements.trim() || null, plan: activePlan, platform, model, + // 水印开启才下发,关闭时不带(服务端按无水印处理) + watermark: settings.watermark.enabled ? settings.watermark : undefined }, + ); + const { suite_id } = await send<{ suite_id: string }>('generateSuite', { + baseUrl: settings.baseUrl, token: settings.token, payload, + }); + pollSuite(suite_id); + } catch (e) { + setGenerating(false); + modal.error({ title: '提交失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } + }; + + /** AI 智能规划出图方案;勾选「规划并生成」时规划完成自动开始生成 */ + const handlePlan = async () => { + if (!result) return modal.warning({ title: '请先采集商品页' }); + const seq = ++planSeqRef.current; + setPlanning(true); + try { + const skuVariants = Array.from(new Set( + result.images.filter(i => i.groupKey === 'sku' && i.variantName).map(i => i.variantName!) + )); + const data = await send<{ summary: string; items: PlanItem[] }>('planSuite', { + baseUrl: settings.baseUrl, token: settings.token, + payload: { + texts: editedTexts(), + sku_variants: skuVariants, + image_stats: result.stats, + platform, + 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); + modal.info({ + title: 'AI 方案已生成', + content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`, + okText: '好的', + }); + } + } catch (e) { + if (seq !== planSeqRef.current) return; // 已重置,过期错误不弹窗 + modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } finally { + if (seq === planSeqRef.current) setPlanning(false); + } + }; + + const totalPlanned = plan.reduce((s, i) => s + i.count, 0); + const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0; + // 分母用后端返回的计划总张数:images 是逐张落库的,生成中 length 永远等于已完成数 + const suiteTotal = suite?.total ?? totalPlanned; + /** 当前风格生效的提示词:用户改写值 > 该风格默认值 */ + const currentStylePrompt = stylePrompts[styleSet] + ?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? ''; + + const handleGenerate = () => { + if (!result) return; + if (selectedKeys.size === 0) return modal.warning({ title: '请先在采集图片区勾选参考图' }); + if (totalPlanned === 0) return modal.warning({ title: '出图方案的张数都是 0' }); + const spec = PLATFORM_SPECS[platform]; + modal.confirm({ + title: '生成电商套图', + content: ( +
+
+ 目标平台「{spec.label}」({spec.lang}文案 · {spec.ratio}),模型「{model}」,风格「 + {STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考图{' '} + {selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。 +
+
+ ), + okText: '开始生成', cancelText: '取消', + onOk: () => startGenerate(), + }); + }; + + const handleExport = async () => { + if (!suite) return; + setExportingZip(true); + try { + const blob = await downloadSuiteZip(settings.baseUrl, settings.token, suite.id); + const objectUrl = URL.createObjectURL(blob); + try { + await chrome.downloads.download({ + url: objectUrl, + filename: `${cleanFilename(productTitle) || '套图'}.zip`, + saveAs: false, + }); + } finally { + // 延迟释放,给下载任务足够时间读取 blob + setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); + } + } catch (e) { + modal.error({ title: '导出失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } finally { + setExportingZip(false); + } + }; + + const toggleKey = (key: string) => { + const next = new Set(selectedKeys); + if (next.has(key)) next.delete(key); else next.add(key); + setSelectedKeys(next); + }; + + /** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */ + const allImages: ImageMaterial[] = result ? [...result.images, ...uploadedImages] : uploadedImages; + + /** 导出文件名的商品标题(编辑态优先,回退采集标题) */ + const productTitle = titleEdit.trim() + || result?.texts.find(t => t.kind === 'title')?.content?.trim() + || ''; + + const groupImages = (groupKey: string): ImageMaterial[] => + allImages.filter(i => i.groupKey === groupKey); + + /** 下载采集图片:只打包已勾选的图片(后端按分组名建文件夹),前端经 chrome.downloads 落盘 */ + const handleExportImages = async () => { + const imgs = allImages.filter(i => i.type === 'img' && selectedKeys.has(i.key)); + if (imgs.length === 0) { + modal.warning({ title: '请先勾选要下载的图片' }); + return; + } + setExporting(true); + try { + const blob = await exportImages(settings.baseUrl, settings.token, { + title: productTitle, + images: imgs.map(i => ({ + url: i.url, + groupName: i.groupName, + variantName: i.variantName ?? null, + key: i.key, + })), + }); + const objectUrl = URL.createObjectURL(blob); + try { + await chrome.downloads.download({ + url: objectUrl, + filename: `${cleanFilename(productTitle) || '采集图片'}.zip`, + saveAs: false, + }); + } finally { + // 延迟释放,给下载任务足够时间读取 blob + setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); + } + } catch (e) { + modal.error({ title: '导出失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); + } finally { + setExporting(false); + } + }; + + /** 预览用的全量图序列(主图→SKU→详情→上传,与展示顺序一致) */ + const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const) + .flatMap(g => groupImages(g).map(i => i.url)); + /** 生成结果的预览序列(仅成功的图) */ + const resultPreviewList: string[] = suite ? suite.images.filter(i => i.status === 'ok').map(i => i.url) : []; + + const toggleGroup = (groupKey: string, on: boolean) => { + const next = new Set(selectedKeys); + groupImages(groupKey).forEach(i => on ? next.add(i.key) : next.delete(i.key)); + setSelectedKeys(next); + }; + + const setPlanCount = (idx: number, count: number) => { + setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p)); + }; + + /** 全部方案是否都启用(任一为 0 即视为未全选,复选框随方案状态联动) */ + const planAllEnabled = plan.every(p => p.count >= 1); + + /** 批量开关:勾选 → 全部至少 1 张(用户已设 >1 的保留原数量);取消勾选 → 全部 0,方便单独勾选一种方案 */ + const togglePlanAll = (on: boolean) => { + setPlan(prev => prev.map(p => on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 })); + }; + + /** 点击方案行:勾选(0 → 1)/ 取消勾选(>0 → 0),配合"全部方案"开关单独选一种 */ + const togglePlanRow = (idx: number) => { + setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p)); + }; + + /** 手动上传:点击触发隐藏的 file input */ + const handleUpload = () => fileRef.current?.click(); + + const onFilesChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + e.target.value = ''; // 清空以便再次选择同名文件 + if (files.length === 0) return; + setUploading(true); + const added: ImageMaterial[] = []; + for (const f of files) { + try { + const { url } = await uploadImage(settings.baseUrl, settings.token, f); + added.push({ + key: `upload-${String(++uploadSeqRef.current).padStart(3, '0')}`, + groupKey: 'upload', + groupName: '手动上传', + url, + thumbUrl: url, + index: added.length, + type: 'img', + }); + } catch (err) { + modal.error({ title: '上传失败', content: `${f.name}:${err instanceof Error ? err.message : String(err)}`, okText: '知道了' }); + } + } + if (added.length > 0) { + setUploadedImages(prev => [...prev, ...added]); + setSelectedKeys(prev => { + const next = new Set(prev); + added.forEach(a => next.add(a.key)); + return next; + }); + modal.success({ title: `已上传 ${added.length} 张图片` }); + } + setUploading(false); + }; + + /** 源站图防盗链时的兜底:走服务端图片代理 */ + const proxied = (u: string) => + `${settings.baseUrl.replace(/\/$/, '')}/api/proxy-image?url=${encodeURIComponent(u)}`; + + /** 缩略图三级降级:thumbUrl → 原图 → 服务端代理 */ + const onThumbError = (e: React.SyntheticEvent, url: string) => { + const el = e.currentTarget; + if (el.dataset.step === '1') { el.dataset.step = '2'; el.src = url; } + else if (el.dataset.step === '2') { el.dataset.step = '3'; el.src = proxied(url); } + }; + + /** 更新水印子设置并落盘(弹窗里控件共用) */ + const patchWatermark = (patch: Partial) => { + const next = { ...settings, watermark: { ...settings.watermark, ...patch } }; + setSettings(next); + saveSettings(next); + }; + + /** 更新上报子设置并落盘 */ + const patchReport = (patch: Partial>) => { + const next = { ...settings, ...patch }; + setSettings(next); + saveSettings(next); + }; + + const settingsPopup = ( +
+ + setSettings({ ...settings, baseUrl: e.target.value })} + onBlur={() => saveSettings(settings)} + /> + + + setSettings({ ...settings, token: e.target.value })} + onBlur={() => saveSettings(settings)} + /> + +
+ {/* ── 商品上报(ozon-seller-kit 后台) ── */} + + {settings.reportEnabled && ( + <> + + patchReport({ reportBaseUrl: e.target.value })} + onBlur={() => saveSettings(settings)} + /> + + + patchReport({ studioBaseUrl: e.target.value })} + onBlur={() => saveSettings(settings)} + /> + +
上报后在浏览器新标签打开该商品的试算页
+ + )} +
+ + {settings.watermark.enabled && ( + <> +
+ +
+ patchWatermark({ type: 'image' })}>图片 + patchWatermark({ type: 'text' })}>文字 +
+
+ {settings.watermark.type === 'text' && ( + + setSettings({ ...settings, watermark: { ...settings.watermark, text: e.target.value } })} + onBlur={() => saveSettings(settings)} + /> + + )} + + setSettings({ ...settings, watermark: { ...settings.watermark, opacity: Number(e.target.value) || 1 } })} + onBlur={() => saveSettings(settings)} + /> + +
位置固定右下角;重新生成后生效,已生成的图不变
+ + )} +
+ ); + + return ( +
+ {/* ── 顶栏 ──(页内模式的收起 ✕ 按钮在宿主层渲染:要骑在面板边缘、一半在面板外, + iframe 会裁剪内容,按钮必须在 iframe 外;Esc 关闭仍走 closePanel) */} +
+
+
+

电商套图工作台

+
商品采集 · 套图生成 · 一键导出
+
+
+ + + + +
+ + {/* ── 目标平台切换(决定文案语言 + 图片比例)── */} +
+ 目标平台 +
+ {PLATFORM_OPTIONS.map(p => ( + + ))} +
+ + {PLATFORM_SPECS[platform].lang}文案 · {PLATFORM_SPECS[platform].ratio} 图片 + +
+ + {/* ── 采集区:左信息 / 右图片,两列等高 ── */} +
+
+ {!result ? ( +
点击右上角「快速采集」抓取当前商品页
+ ) : ( + <> + + setTitleEdit(e.target.value)} /> + +
+
+ + setPriceEdit(e.target.value)} /> + +
+
+ + + +
+
+ +
+ setDimsEdit(d => ({ ...d, l: e.target.value }))} /> + × + setDimsEdit(d => ({ ...d, w: e.target.value }))} /> + × + setDimsEdit(d => ({ ...d, h: e.target.value }))} /> +
+
+ + setWeightEdit(e.target.value)} /> + + {(sales || shop) && ( +
+
+ + + +
+
+ + + +
+
+ )} + {paramPairs.length > 0 && ( +
+ + {paramsOpen && ( + + + {paramPairs.slice(0, 30).map((p, i) => ( + + ))} + +
{p.key}{p.value}
+ )} +
+ )} + +