feat: AI 生图、图片下载优化

This commit is contained in:
Joey
2026-08-29 17:13:06 +08:00
parent a77ec26a02
commit 4ee667142b
20 changed files with 683 additions and 248 deletions
+4 -2
View File
@@ -38,8 +38,10 @@ APP_BASE_URL=http://127.0.0.1:8800
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
# 豆包 Seedream(火山方舟)
ARK_API_KEY=
# RightAPI 中转(gpt-image-2 / gpt-image-2-vip / nano-banana 系列)
# RightCode 中转 rightapi.ai(gpt-image-2 / gpt-image-2-vip / nano-banana 系列)
RIGHTAPI_API_KEY=
# 可选覆盖:默认生图 provider(doubao | tongyi | rightapi)、水印徽章图路径
# Pcore 中转 pcore.ai(模型下拉里选「gpt-image-2(Pcore)」时使用)
PCORE_API_KEY=
# 可选覆盖:默认生图 provider(doubao | tongyi | rightapi | pcore)、水印徽章图路径
# IMAGE_PROVIDER=doubao
# WATERMARK_IMAGE_PATH=server/assets/watermark.jpg
+86 -46
View File
@@ -1,7 +1,7 @@
# 工作交接说明(HANDOFF)
> 更新:2026-08-28 晚
> 读者:下一个接手工作的 AI / 开发者。先读本文再动手,避免重复探索。
> 更新:2026-08-29 晚
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
---
@@ -22,78 +22,118 @@ extensions/collector 扩展采集(Ozon/1688/淘宝/天猫)
## 2. 启动与构建
```bash
# server(我最后用的方式;日志在 /tmp/osk-server.log)
cd ozon-seller-kit
# 一键启动(推荐):自动装依赖 → 构建采集插件 → 起插件改动监听 → 起 studio → 起 server
./start.command
# server 单独起(等价;日志建议重定向到 /tmp/osk-server.log)
.venv/bin/uvicorn main:app --app-dir server --reload --reload-include "*.yaml" --host 127.0.0.1 --port 8800
# studio
pnpm -C studio dev # http://localhost:8900,/api 代理到 8800
# 扩展(改了扩展代码后必跑,然后 chrome://extensions 刷新)
pnpm -C extensions/collector install # 首次
# 扩展改动后(start.command 的监听会自动构建,仍需去 chrome://extensions 手动点刷新)
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
```
密钥在根目录 `.env`(已配好):`DEEPSEEK_API_KEY`(规划+文案)、`RIGHTAPI_API_KEY`(生图主力:gpt-image-2-vip / nano-banana 系列)、`DASHSCOPE_API_KEY`(通义,可选)、`ARK_API_KEY` 未配(豆包未用)。
- **start.command 新增**:启动时自动构建采集插件,并起后台监听(3s 轮询 `src/**` + `wxt.config.ts`,改动自动重建;BSD find 语法,无新依赖)。Ctrl+C 会连同监听进程一起清理。
- **运行态提醒**:交接时 server 是上个会话用 nohup 起的后台进程(日志 `/tmp/osk-server.log`),原 start.command 终端已被停掉。新会话建议:`kill $(lsof -ti :8800)` 后用 `./start.command` 正常接管。
- **`uvicorn --reload` 不监控 `.env`**:改 `.env` 后必须手动重启 server(Kill 8800 端口进程再起)。
- 密钥在根目录 `.env`:`DEEPSEEK_API_KEY`(规划+文案)、`DASHSCOPE_API_KEY`(通义,可用 ✅)、`RIGHTAPI_API_KEY`(RightCode 中转,**通道当前故障** ❌)、`PCORE_API_KEY`(pcore.ai,**可用 ✅**)、`ARK_API_KEY` 未配(豆包未用)。`.env.example` 有全部键位说明。
## 3. 当前进度
## 3. 生图供应商现状(2026-08-29,重要)
### ✅ 已完成(已提交,git log 有记录)
| 阶段 | 内容 |
| 供应商 | 域名 | 模型(下拉显示) | 状态 |
|---|---|---|---|
| 通义/阿里 | dashscope.aliyuncs.com | `qwen-image-3.0-pro` 等(阿里巴巴) | ✅ 可用,实测 10s 出图 |
| RightCode | rightapi.ai | `gpt-image-2`、`gpt-image-2-vip`、`nano-banana` 系列(RightCode) | ❌ 通道故障:任务能提交但生成阶段 503/「No available channel」,纯文生图也失败;中转侧问题,待其恢复 |
| Pcore | pcore.ai | `gpt-image-2@pcore`(Pcore) | ✅ 可用(用户已在 pcore 开通 image2 分组后端到端跑通,全链路约 1 分钟/张) |
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);生图全局串行(`generator.py` 的 `asyncio.Lock`),远碰不到 RPM。pcore 账户余额 $5,按张扣费。
## 4. AI 规划张数策略(`planner.py`)
- **不设硬性数量**:prompt 让 AI 按商品复杂度自主决定,「建议 8 张左右」;规划规则 = 先保证每个 SKU 1 张主图,其余类型自行取舍。
- **用户可通过「生图要求」指定张数**:写了「N 张」AI 必须严格执行;服务端 `_total_budget()` 用正则提取,安全上限:默认 15、指定时按 N 最大 24(防跑飞,超限从尾部截断,SKU 主图优先保留)。
- **规划器曾频繁报「规划器未返回有效方案项」**:根因是 deepseek-v4 是思维链模型而规划器裸调用没禁 thinking,token 被推理烧光、正文为空。已修复:请求体加 `"thinking": {"type": "disabled"}` + 解析失败/空方案自动重试一次 + 失败时记录原始输出 + 缺 title 的方案项从 detail/kind 兜底合成不再丢弃。30 SKU 压测 3/3 通过。
## 5. 当前进度
### ✅ 已完成(含 2026-08-29 一轮,**全部未提交**,见 §6)
| 模块 | 内容 |
|---|---|
| Phase A | 试算页前端 `/trial/:id` 五区块:1 商品信息 / 2 价格试算(公式对齐 v1 web)/ 3 俄文文案 / 4 采集图片+出图方案+生成结果 / 5 入库与导出 |
| Phase C | ISS 扩展并入 `extensions/collector/`;「上报商品」按钮(01 区块下方)→ 入库 + 新标签打开试算页;设置弹窗含上报开关/后台地址/试算页地址 |
| Phase B | 生图服务端(`server/api/suite.py` + `services/{planner,generator,prompts/,tasks,watermark}`,平移自 image-suite-studio) |
| 后端整理 | 鉴权全删(server+studio);冻结链路隔离 `server/legacy/`(接口仍挂载);`collection.py`→`materials.py`;suite 业务下沉 `services/suite_service.py` |
| 试算页 | 五区块页面、计价、俄文文案、采集图片/出图方案/生成结果、登记导出 |
| 采集对接 | ISS 扩展并入 `extensions/collector/`,「上报商品」入库 + 自动打开试算页 |
| 生图服务端 | planner/generator/prompts/watermark/tasks,套图 + 单张 AI 生图 + 水印 + ZIP 导出 |
| **本轮:Pcore 接入** | 新 provider 全链路(生成/编辑/轮询/重试/错误处理),`@pcore` 路由约定,下拉带供应商名,默认模型切 Pcore |
| **本轮:规划器修复** | thinking 禁用 + 重试 + 张数策略(AI 自主,建议 8 张左右,生图要求可指定) |
| **本轮:采集箱** | 勾选列 + 全选/取消全选 + 批量删除(硬删、Popconfirm 二次确认、删空页自动回退) |
| **本轮:采集图片区改版** | 8 列网格;AI 生图按钮半透明悬浮图片右下角;图名显示在图片下方(取 URL 末段);SKU 名并入图名行(不再悬浮遮挡勾选钮);勾选钮在右上角;默认全部不勾选 |
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) |
| **本轮:关键 bug 修复** | ① axios 全局 Content-Type 导致 FormData 被 JSON 化 → 上传 422(`api.ts` 移除全局头,**今后勿设**);② TrialPage `load()` 置 loading 导致整页闪 Spin、卸载面板(杀生图轮询/丢勾选)→ 改静默刷新;③ web v1 页面因 cdn.tailwindcss.com 被墙整页无样式 → tailwind 运行时固化到 `web/vendor/tailwind.js` |
| **本轮:杂项** | 采买地址「打开地址」改图标按钮(与复制图标同款);start.command 自动构建+监听插件 |
### ✅ 已联调验证(真实调用生图 API)
plan / generate(串行队列+轮询+回写 generated)/ image-edit(单张,含 `after_asset_id` 插入原图后)/ 水印(文字 Panda Store 右下角)/ export/images(ZIP 分组结构)/ proxy-image / DELETE /api/assets/{id}(删素材+文件)/ materials 上报(product_id 返回)。
### 验证口径(本轮均实测通过)
### 🔨 最近一轮 UI 迭代(**部分未提交**,见 git status)
- 出图方案卡:左右 12:12;左列灰卡内含「规划并生成 + AI 智能规划」;数量改 Stepper(− 输入 +,0-5);AI 智能规划按钮高度与一键生图一致;说明文字支持换行
- 水印设置移到「6 生成结果」标题栏最右;默认文本改 `Panda Store`
- 生成结果卡:新增「单张 AI 生图」小节(含套图回写与单张图);底部「AI 编辑」可点击 → 同款生图弹窗,新图经 `after_asset_id` 插在原图后;每张图右上角 ✕ 删除(Popconfirm 二次确认 → `DELETE /api/assets/{id}`)
- 采集图片卡不再显示 generated 素材(归生成结果卡)
- 型号/货号:后缀独立存储(货号框 `addonBefore` 展示 `型号-`,只输后缀;型号/货号框后有复制按钮);型号含 `-` 不再增殖
- 侧边栏:展开 200 / 折叠 60,折叠图标居中(`SidebarMenu.css` 尾部规则)
- 采买地址:「打开地址」按钮 + 复制图标;仅 1688/淘宝/天猫/拼多多来源自动填充
- 试算页水印弹窗默认文本 Panda Store(注意 localStorage `trialWatermark` 有旧值需手改一次)
- 上传/生图/导出/批量删除/规划,全部真实接口+浏览器操作验证;Pcore 端到端 56s 出图并回写素材。
- 每个 UI 改动后 `pnpm -C studio exec tsc -b` 零错误;插件构建通过;`/api/health` 200。
## 4. 下一步(按优先级)
## 6. ⚠️ 未提交改动清单(当前最大事项)
1. **提交当前改动**:`git status` 有一批未提交(后端整理 + 最近 UI 迭代 + extensions settings 改动),建议按模块分 2-3 个 commit
2. **手动全流程回归**(浏览器 + Chrome 真机扩展):采集 → 上报 → 试算页五区块 → 生成/编辑/删除 → 登记表录入/导出 CSV/组合码 → 清空
3. **Phase D 待办**:批量 CSV 服务端化(`GET /api/export/trial-csv`,替代前端逐个拉详情拼装);登记表数据当前存 localStorage(换设备不可见,如需跨设备要落库)
4. **远期**:鉴权/账户体系重做(从 `server/deps.py` 层重新引入);ISS-only 产品模式构建配置;`extension-v1/v2`、`web/`、`server/legacy/` 的清理时机
分支 `feat-2.1.0`,工作区有 19 个文件改动 + 1 个新目录,**建议按模块分 3-4 个 commit**:
## 5. 已知坑(新会话必读)
1. **Pcore 生图接入 + 规划器修复 + 张数策略**
`server/services/{generator,planner,suite_service,prompts/__init__}.py`、`server/{api/suite.py,schemas/suite.py,config/settings.py}`、`studio/src/services/suite.ts`、`.env.example`、`docs/v2.1/{image-suite,api}.md`
2. **试算页 UI 迭代 + 采集箱批量操作 + 请求层修复**
`studio/src/pages/trial/{TrialSuitePanel,TrialInfoPanel,TrialPage}.tsx`、`studio/src/pages/collection/CollectionPage.tsx`、`studio/src/services/api.ts`
3. **web v1 样式修复(CDN 固化)+ start.command 插件自动构建**
`web/ozonSeller.html`、`web/vendor/tailwind.js`(新目录)、`start.command`
4. **交接文档**:`docs/v2.1/HANDOFF.md`
提交前:`git checkout -- studio/tsconfig.app.tsbuildinfo`(构建产物,勿提交)。
## 7. 下一步(按优先级)
1. **提交**:按 §6 分组提交。
2. **手动全流程回归**(浏览器 + Chrome 真机扩展):采集 → 上报 → 试算页五区块 → 生成(Pcore)/编辑/删除 → 登记表录入/导出 CSV/组合码 → 清空。
3. **运营观察**:pcore 余额($5 起步)与 image2 分组日限(1.2K 张/天);RightCode 通道恢复情况(恢复后 nano-banana 系列可回归)。
4. **Phase D 待办**:批量 CSV 服务端化(`GET /api/export/trial-csv`);登记表数据存 localStorage(跨设备需落库)。
5. **远期**:鉴权/账户体系重做(从 `deps.py` 层重引);ISS-only 产品模式构建;`extension-v1/v2`、`web/`、`server/legacy/` 清理时机。
## 8. 已知坑(新会话必读)
| # | 坑 | 对策 |
|---|---|---|
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢 |
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢(在「生图记录」区) |
| 3 | 生图全局串行(`generator.py` 的 `asyncio.Lock`) | 一次只跑一个生成队列,测试时别并发提交 |
| 4 | 单张 image-edit 是同步接口 | gpt 系列单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生成结果) |
| 5 | **ZCode IAB 浏览器调试 quirks**:`press`/Backspace 键注入在该页时灵时不灵(`fill` 可靠);`fullPage` 截图偶发失败;文件上传不可用;改代码后页面 React 事件偶发失灵 → **reload 页面**即恢复 | 优先用 `fill`/`domSnapshot`/`getBoundingClientRect` 的 evaluate(注意 evaluate 可能被拒 side-effect,读值即可) |
| 6 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
| 7 | `studio/tsconfig.app.tsbuildinfo` 是构建产物 | 提交前 `git checkout -- studio/tsconfig.app.tsbuildinfo` |
| 8 | 冻结代码不要投入 | `server/legacy/`、`extension-v1/`、`extension-v2/`、`web/`、studio 商品编辑页的属性映射/发布区块 |
| 9 | AI 规划/生图花钱 | rightapi 按张计费;联调时 plan 张数设 1-2、优先 `nano-banana-2-lite`(快/便宜) |
| 4 | 单张 image-edit 是同步接口 | 慢模型单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生图记录) |
| 5 | **axios 实例勿设全局 Content-Type** | axios 1.x 遇「默认 JSON 头 + FormData」会把表单转 JSON 发送(`formDataToJSON`),multipart 接口全 422。已从 `api.ts` 移除;对象请求体 axios 会自动补 JSON 头 |
| 6 | `web/` 样式依赖公网 CDN | tailwind 已固化 `web/vendor/tailwind.js`;font-awesome/jsdelivr 若也断,同样方式下载到 `web/vendor/` 改引用 |
| 7 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
| 8 | `studio/tsconfig.app.tsbuildinfo` 是构建产物 | 提交前 `git checkout -- studio/tsconfig.app.tsbuildinfo` |
| 9 | 冻结代码不要投入 | `server/legacy/`、`extension-v1/`、`extension-v2/`、`web/`(本轮 CDN 固化属修复例外)、studio 商品编辑页的属性映射/发布区块 |
| 10 | AI 规划/生图花钱 | pcore 按 $ 扣费、RightCode 按张计费;联调时 plan 张数设 1-2、优先低成本模型 |
| 11 | TrialPage `load()` 勿再置 loading | 置 loading = 整页 Spin + 卸载面板 = 杀生图轮询/丢状态(已修,保持静默刷新) |
| 12 | 浏览器自动化调试怪癖(如 ZCode IAB) | 截图偶发超时(重试一次多半仍失败,改用几何测量/计算样式验证);文件上传需用 DataTransfer+input change 事件模拟;改代码后 React 事件偶发失灵 → reload 页面即恢复 |
## 6. 关键文件速查
## 9. 关键文件速查
| 功能 | 前端(studio/src/) | 后端(server/) |
|---|---|---|
| 试算页骨架 | `pages/trial/TrialPage.tsx` | — |
| 01 商品信息(型号/货号联动、采买地址) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py`(上报/删素材) |
| 试算页骨架 | `pages/trial/TrialPage.tsx`(load() 是静默刷新,见坑 11) | — |
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
| 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` |
| 04 图片与生图 | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{suite_service,generator,planner,prompts/,tasks,watermark}.py` |
| 04 图片与生图(8 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{suite_service,generator,planner,prompts/,tasks,watermark}.py` |
| 生图供应商路由 | `services/suite.ts`(IMAGE_MODEL_OPTIONS 带供应商名;DEFAULT_IMAGE_MODEL) | `schemas/suite.py`(MODEL_PROVIDERS/`@pcore` 约定)+ `services/generator.py`(generate_pcore 等) |
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
| 采集箱(全选/批量删除) | `pages/collection/CollectionPage.tsx` | `api/products.py`(DELETE hard=true) |
| 前端服务层 | `services/{suite,product,ai,fx,api}.ts` | `schemas/suite.py`(契约对齐) |
| 扩展上报 | `extensions/collector/src/api/report.ts` + `entrypoints/{background,sidepanel/App}.tsx` + `storage/settings.ts` | `api/materials.py` |
## 7. 验收标准(改完怎么算完成)
## 10. 验收标准(改完怎么算完成)
1. `pnpm -C studio exec tsc -b` 零错误、`pnpm -C studio build` 通过(提交前还原 `tsconfig.app.tsbuildinfo`)
2. 改扩展:`pnpm -C extensions/collector build` 通过
+1 -1
View File
@@ -33,7 +33,7 @@
```
**Res** `{ "summary": "一句话方案说明", "items": [ { "kind": "white_bg", "title": "白底主图", "detail": "…", "prompt_hint": "…", "count": 2, "variant_name": "粉色|null" } ] }`
实现要点:DeepSeek 单行紧凑 JSON;kind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总数建议 8-15。同步接口(数秒级)。
实现要点:DeepSeek 单行紧凑 JSON;kind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总张数 AI 自主(建议 8 张左右;安全上限:默认 15,生图要求指定「N 张」时按 N、最大 24)。同步接口(数秒级)。
## 3. 一键生成 `POST /api/suite/generate`(Phase B)
+1 -1
View File
@@ -30,7 +30,7 @@
- **类型白名单(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。
- **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。**总张数由 AI 自主决定(不设硬性数量,建议 8 张左右)**;「生图要求」写「N 张」可指定张数(AI 必须执行);服务端只设防跑飞安全上限(默认 15、指定时按 N 最大 24),超限从尾部截断(2026-08-29)。
- **规划并生成**:勾选后规划完成直接进入生成。
- SKU 规格来源:`product_assets` 中 `group_key='sku'` 的 `variant_name` 去重。
+5 -3
View File
@@ -28,6 +28,7 @@ from schemas.suite import (
SuiteGenerateRequest,
SuiteOut,
SuitePlanRequest,
api_model,
resolve_provider,
)
from services.generator import _image_size, GENERATORS, run_suite
@@ -98,9 +99,10 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
spec = PLATFORM_SPECS[req.platform]
settings = get_settings()
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi)
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi、gpt-image-2@pcore → pcore)
provider_name = resolve_provider(req.model, None, settings.image_provider)
validate_model(provider_name, req.model)
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
product_id = (req.product_id or "").strip() or None
if product_id:
@@ -118,7 +120,7 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
style_prompt=req.style_prompt,
requirements=req.requirements,
provider=provider_name,
model=req.model,
model=model,
total=len(jobs),
context=texts_to_raw(req.texts),
plan=jobs,
@@ -214,7 +216,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
validate_model(provider_name, req.model)
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
model = req.model
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
is_wan = provider_name == "tongyi" and model.lower().startswith("wan")
# 复用套图的 prompt 家族:custom 类型装配「用户要求」为最高优先级约束,
+6
View File
@@ -72,6 +72,12 @@ class Settings(BaseSettings):
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
# Pcore(OpenAI 兼容中转:pcore.ai,异步图片任务;模型值带 @pcore 后缀路由到此)
pcore_api_key: str = ""
pcore_base_url: str = "https://pcore.ai"
pcore_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
pcore_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
# DeepSeek 出图方案规划器(key 复用上方 deepseek_api_key;文案生成的模型走 config/models.yaml,互不影响)
deepseek_base_url: str = "https://api.deepseek.com/v1"
deepseek_model: str = "deepseek-v4-flash"
+16 -6
View File
@@ -24,15 +24,25 @@ RIGHTAPI_MODELS = [
"nano-banana-pro",
]
# 模型 → provider 推断表:前端只传模型名,服务端据此路由(模型名优先于 provider 字段)
MODEL_PROVIDERS: dict[str, str] = {
**{m: "tongyi" for m in TONGYI_MODELS},
**{m: "rightapi" for m in RIGHTAPI_MODELS},
}
# Pcore 生图模型白名单(pcore.ai 中转;同一模型名与 RightAPI 重名,用 @pcore 后缀区分路由)
PCORE_MODELS = ["gpt-image-2"]
# 豆包 Seedream 走火山方舟;模型由 ARK_IMAGE_MODEL 指定(不在下拉白名单内也允许直连豆包)
DOUBAO_MODELS = ["doubao-seedream-4-5-251128"]
MODEL_PROVIDERS.update({m: "doubao" for m in DOUBAO_MODELS})
# 模型 → provider 推断表:前端只传模型名,服务端据此路由(模型名优先于 provider 字段)
# 同名模型走多家中转时,前端传「api_model@provider」形式(如 gpt-image-2@pcore)
MODEL_PROVIDERS: dict[str, str] = {
**{m: "tongyi" for m in TONGYI_MODELS},
**{m: "rightapi" for m in RIGHTAPI_MODELS},
**{m: "doubao" for m in DOUBAO_MODELS},
**{f"{m}@pcore": "pcore" for m in PCORE_MODELS},
}
def api_model(model: str | None) -> str | None:
"""前端模型值 → 发给供应商 API 的模型名:'gpt-image-2@pcore' → 'gpt-image-2'。"""
return model.split("@", 1)[0] if model and "@" in model else model
def resolve_provider(model: str | None, requested: str | None, default: str) -> str:
+111 -3
View File
@@ -61,9 +61,11 @@ def _raise_api_error(resp, provider: str):
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail}", resp.status_code)
try:
body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001
detail = text[:200]
raise ApiError(f"{provider} API HTTP {resp.status_code} — {text[:200]}", resp.status_code)
# 兼容两种错误形状:顶层 {code, message}(豆包/通义)与嵌套 {error: {code, message}}(new_api 系中转)
err = body.get("error") if isinstance(body.get("error"), dict) else body
detail = f"{err.get('code', '')}: {err.get('message', '')}".strip(': ')
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code)
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
@@ -80,6 +82,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
if provider == "rightapi":
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
if provider == "pcore":
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
return "1024x1536" if ratio == "3:4" else "1024x1024"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
@@ -379,7 +384,110 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204
raise last_exc # type: ignore[misc]
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
# ── Provider:Pcore(pcore.ai,OpenAI 兼容异步图片协议)──────────────────
async def _pcore_poll_task(client: httpx.AsyncClient, headers: dict, base: str,
kind: str, task_id: str, max_wait: int) -> dict:
"""轮询 Pcore 任务:GET {base}/v1/images/{kind}/{task_id},kind ∈ generations | edits。
状态机:queued / in_progress → 继续;completed / succeeded → 返回;
failed / error / cancelled → 抛错。
"""
poll_url = f"{base}/v1/images/{kind}/{task_id}"
elapsed, interval = 0, 3
while elapsed < max_wait:
resp = await client.get(poll_url, headers=headers, timeout=30)
_raise_api_error(resp, "Pcore")
result = resp.json()
status = result.get("status", "")
if status in ("failed", "error", "cancelled"):
err = result.get("error") or {}
raise RuntimeError(f"Pcore 任务失败: {err.get('message') or result}")
if status in ("completed", "succeeded"):
return result
await asyncio.sleep(interval)
elapsed += interval
interval = min(interval + 2, 10)
raise TimeoutError(f"Pcore 异步任务超时 ({max_wait}s): task_id={task_id}")
async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""Pcore:无参考图走 /v1/images/generations,带参考图走 /v1/images/edits(JSON,data-URI)。
两者均为异步:提交返回任务 id → 轮询对应资源路径 → 完成后 data[0].url 取图。
完成形状与 RightAPI 同为 Images 协议,复用 _rightapi_extract_image。
"""
base = s.pcore_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.pcore_api_key}"}
kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
resp = await client.post(
f"{base}/v1/images/{kind}",
headers={**headers, "Content-Type": "application/json"},
json=body,
)
_raise_api_error(resp, "Pcore")
submitted = resp.json()
# new_api 系中转常见「HTTP 200 + 错误体」,需主动检查 error 字段
if isinstance(submitted, dict) and submitted.get("error"):
err = submitted["error"] or {}
raise RuntimeError(f"Pcore 提交失败: {err.get('message') or submitted}")
task_id = submitted.get("task_id") or submitted.get("id") or ""
if not task_id:
raise RuntimeError(f"Pcore 提交未返回任务 id: {submitted}")
result = await _pcore_poll_task(client, headers, base, kind, task_id, s.poll_max_wait)
img_kind, payload = _rightapi_extract_image(result)
if img_kind == "b64" and payload:
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
if img_kind == "url" and payload:
dl = await client.get(payload, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
raise RuntimeError(f"Pcore 任务完成但没有图片数据: {result}")
async def generate_pcore(prompt: str, ref_images: list[str], size: str = "1536x2048", model: str | None = None) -> bytes:
"""带重试的 Pcore 入口:429/5xx/超时按递增间隔重试(与 RightAPI 同策略)。"""
s = get_settings()
if not s.pcore_api_key:
raise RuntimeError("未配置 PCORE_API_KEY(.env)")
model = model or "gpt-image-2"
attempts = max(1, s.pcore_max_retries)
last_exc: Exception | None = None
for i in range(attempts):
try:
return await _pcore_request(s, prompt, ref_images, size, model)
except ApiError as exc:
last_exc = exc
if exc.status not in RETRYABLE_STATUS:
raise
# 通道不存在(模型未开通)重试无意义,立即失败
if "no available channel" in str(exc).lower() or "model_not_found" in str(exc).lower():
raise
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc # 网络抖动/超时可重试
if i == attempts - 1:
break
wait = s.pcore_retry_wait * (2 ** i)
log.warning("Pcore 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
await asyncio.sleep(wait)
raise last_exc # type: ignore[misc]
GENERATORS = {
"doubao": generate_doubao,
"tongyi": generate_tongyi,
"rightapi": generate_rightapi,
"pcore": generate_pcore,
}
# ── 任务执行器 ────────────────────────────────────────────────────────────
+88 -60
View File
@@ -22,23 +22,39 @@ ALLOWED_KINDS = [
"size_chart", "sku_collection", "custom",
]
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
PLAN_SAFETY_MAX = 15
PLAN_REQUIREMENTS_MAX = 24
def _total_budget(requirements: str | None) -> int:
"""从生图要求提取「N 张」作为总张数预算;未指定时用默认安全上限。"""
if requirements:
m = re.search(r"(\d+)\s*张", requirements)
if m:
return min(max(int(m.group(1)), 1), PLAN_REQUIREMENTS_MAX)
return PLAN_SAFETY_MAX
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
## 输出硬性约束(违反即失败)
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数 8-15。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
2. 场景图(kind=lifestyle):按核心使用场景出 2-4 张,每张聚焦一个场景。
3. 细节图(kind=material 或 custom):按关键细节/材质/结构出 2-3 张,每张聚焦一个卖点。
4. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
5. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
6. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
7. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
1. 先保证 SKU 主图(规则 2),其余类型(场景/细节/尺寸/合集等)按商品特点自行取舍并分配张数,追求信息效率,不为凑数出图。
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
3. 场景图(kind=lifestyle):按核心使用场景出 1-3 张,每张聚焦一个场景。
4. 细节图(kind=material 或 custom):按关键细节/材质/结构出 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
6. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
## 输出示例(紧凑单行)
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
@@ -78,9 +94,8 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
kind = str(it.get("kind") or "custom")
if kind not in ALLOWED_KINDS:
kind = "custom"
title = str(it.get("title") or "").strip()[:20]
if not title:
continue
# title 缺失不再丢弃条目:从 detail / kind 兜底合成,避免整份方案被判空
title = str(it.get("title") or "").strip()[:20] or str(it.get("detail") or "").strip()[:12] or kind
try:
count = max(0, min(3, int(it.get("count", 1))))
except (TypeError, ValueError):
@@ -173,55 +188,68 @@ async def generate_plan(
user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip()
user_content = json.dumps(user_payload, ensure_ascii=False)
async with httpx.AsyncClient(timeout=90, verify=False) as client:
resp = await client.post(
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"},
json={
"model": s.deepseek_model,
"messages": [
{"role": "system", "content": _system_prompt_with_requirements(requirements)},
{"role": "user", "content": user_content},
],
"response_format": {"type": "json_object"},
"temperature": 0.3,
"max_tokens": 8000,
},
)
resp.raise_for_status()
body = resp.json()
message = body["choices"][0]["message"]
finish_reason = body["choices"][0].get("finish_reason", "")
usage = body.get("usage") or {}
log.info(
"规划器 token 用量: prompt=%s completion=%s finish=%s",
usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"), finish_reason,
)
content = message.get("content") or ""
# 思考型输出:content 为空时从 reasoning_content 里捞
if not content.strip() and message.get("reasoning_content"):
content = message["reasoning_content"]
last_error: RuntimeError | ValueError | None = None
for attempt in range(2): # 解析失败/空方案自动重试一次
async with httpx.AsyncClient(timeout=90, verify=False) as client:
resp = await client.post(
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"},
json={
"model": s.deepseek_model,
"messages": [
{"role": "system", "content": _system_prompt_with_requirements(requirements)},
{"role": "user", "content": user_content},
],
"response_format": {"type": "json_object"},
"temperature": 0.3,
"max_tokens": 8000,
# deepseek-v4 是思维链模型:不禁用 thinking 会把 max_tokens 烧在推理上,
# 正文为空 → 只能拿 reasoning_content 兜底解析 → 频发「解析失败/未返回有效方案项」
"thinking": {"type": "disabled"},
},
)
resp.raise_for_status()
body = resp.json()
message = body["choices"][0]["message"]
finish_reason = body["choices"][0].get("finish_reason", "")
usage = body.get("usage") or {}
log.info(
"规划器 token 用量: prompt=%s completion=%s finish=%s",
usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"), finish_reason,
)
content = message.get("content") or ""
# 思考型输出:content 为空时从 reasoning_content 里捞
if not content.strip() and message.get("reasoning_content"):
content = message["reasoning_content"]
try:
data = _extract_json(content)
except ValueError as exc:
log.error(
"规划器输出解析失败 finish_reason=%s content[:200]=%s",
finish_reason, content[:200],
try:
data = _extract_json(content)
except ValueError as exc:
last_error = exc
log.error(
"规划器第 %d 次输出解析失败 finish_reason=%s content[:200]=%s",
attempt + 1, finish_reason, content[:200],
)
continue
items = _normalize_items(data.get("items") or [], sku_variants)
if items:
# 总量保护:只防跑飞不干预正常规划。生图要求指定「N 张」时按 N 放宽上限
budget = _total_budget(requirements)
total = sum(i["count"] for i in items)
while total > budget and items:
last = items[-1]
if last["count"] > 1:
last["count"] -= 1
else:
items.pop()
total = sum(i["count"] for i in items)
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
last_error = RuntimeError("规划器未返回有效方案项")
log.warning(
"规划器第 %d 次返回无有效方案项 finish_reason=%s content[:200]=%s",
attempt + 1, finish_reason, content[:200],
)
raise RuntimeError("规划器输出解析失败") from exc
items = _normalize_items(data.get("items") or [], sku_variants)
if not items:
raise RuntimeError("规划器未返回有效方案项")
# 总量保护:超过 18 张时按比例截断
total = sum(i["count"] for i in items)
while total > 18 and items:
last = items[-1]
if last["count"] > 1:
last["count"] -= 1
else:
items.pop()
total = sum(i["count"] for i in items)
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
raise last_error or RuntimeError("规划器未返回有效方案项")
+1 -1
View File
@@ -25,7 +25,7 @@ _MODULE_BY_FAMILY = {
def prompt_family(provider: str, model: str | None) -> str:
"""(provider, model) → 提示词家族名。"""
if provider == "rightapi":
if provider in ("rightapi", "pcore"):
if (model or "").lower().startswith("nano-banana"):
return "google"
return "gpt" # gpt-image-* 及未知中转模型默认按 edits 语义处理
+8 -5
View File
@@ -38,12 +38,15 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
def validate_model(provider_name: str, model: str | None) -> None:
"""按 provider 校验模型名白名单,不合法抛 ValueError。"""
from schemas.suite import RIGHTAPI_MODELS, TONGYI_MODELS
from schemas.suite import PCORE_MODELS, RIGHTAPI_MODELS, TONGYI_MODELS, api_model
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
raise ValueError(f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
raise ValueError(f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
m = api_model(model) # 'gpt-image-2@pcore' → 'gpt-image-2'
if provider_name == "tongyi" and m and m not in TONGYI_MODELS:
raise ValueError(f"不支持的模型: {m}(tongyi 支持: {TONGYI_MODELS})")
if provider_name == "rightapi" and m and m not in RIGHTAPI_MODELS:
raise ValueError(f"不支持的模型: {m}(rightapi 支持: {RIGHTAPI_MODELS})")
if provider_name == "pcore" and m and m not in PCORE_MODELS:
raise ValueError(f"不支持的模型: {m}(pcore 支持: {PCORE_MODELS})")
async def append_generated_asset(
+45 -2
View File
@@ -24,17 +24,60 @@ if [[ ! -f .env ]]; then
exit 1
fi
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)"
watch_collector() {
touch "$COLLECTOR_STAMP"
while true; do
sleep 3
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
touch "$COLLECTOR_STAMP"
echo "🔧 检测到插件代码改动,重新构建…"
if pnpm -C extensions/collector build >/dev/null 2>&1; then
echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效"
else
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
fi
fi
done
}
if [[ ! -d extensions/collector ]]; then
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
elif ! command -v pnpm >/dev/null 2>&1; then
echo "⚠️ 未找到 pnpm,本次跳过采集插件构建(可手动:pnpm -C extensions/collector build)"
elif ! CI=true pnpm -C extensions/collector install --silent; then
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
else
echo "正在构建采集插件…"
if pnpm -C extensions/collector build; then
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
else
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
fi
watch_collector &
COLLECTOR_WATCH_PID=$!
fi
echo
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
STUDIO_PID=""
stop_studio() {
cleanup() {
if [[ -n "$STUDIO_PID" ]] && kill -0 "$STUDIO_PID" 2>/dev/null; then
kill "$STUDIO_PID" 2>/dev/null
sleep 0.2
# 兜底:确保 vite 及其子进程退出
lsof -ti :8900 | xargs kill 2>/dev/null
fi
if [[ -n "$COLLECTOR_WATCH_PID" ]] && kill -0 "$COLLECTOR_WATCH_PID" 2>/dev/null; then
pkill -P "$COLLECTOR_WATCH_PID" 2>/dev/null # 可能正在跑的 build 子进程
kill "$COLLECTOR_WATCH_PID" 2>/dev/null
fi
rm -f "$COLLECTOR_STAMP" 2>/dev/null
}
trap stop_studio EXIT
trap cleanup EXIT
if ! command -v pnpm >/dev/null 2>&1; then
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
+78 -28
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, type Key } from 'react';
import { useNavigate } from 'react-router';
import { Button, Card, Input, message, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -43,6 +43,7 @@ export default function CollectionPage() {
const res = await listProducts({ stage, q: q || undefined, page, page_size: pageSize });
setItems(res.items);
setTotal(res.total);
setSelectedKeys([]);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
@@ -76,6 +77,26 @@ export default function CollectionPage() {
}
};
// 勾选与当前列表视图绑定:每次 load 后清空,避免残留不可见商品的勾选
const [selectedKeys, setSelectedKeys] = useState<Key[]>([]);
const allSelected = items.length > 0 && selectedKeys.length === items.length;
const toggleSelectAll = () => {
setSelectedKeys(allSelected ? [] : items.map((i) => i.id));
};
const onBatchDelete = async () => {
const ids = [...selectedKeys];
const results = await Promise.allSettled(ids.map((id) => deleteProduct(String(id), true)));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail > 0) message.warning(`已删除 ${ok} 个商品,${fail} 个失败`);
else message.success(`已删除 ${ok} 个商品`);
setSelectedKeys([]);
// 当前页删空时回退一页,避免停在空页
if (ok > 0 && ok === items.length && page > 1) setPage(page - 1);
else load();
};
const columns: ColumnsType<ProductListItem> = [
{
title: '商品名',
@@ -140,38 +161,67 @@ export default function CollectionPage() {
<Title level={4} style={{ marginTop: 0 }}>
采集箱
</Title>
<Space style={{ marginBottom: 16 }} wrap>
<Input.Search
placeholder="搜索名称 / 货号"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setQ(v);
}}
/>
<select
value={stage ?? ''}
onChange={(e) => {
setPage(1);
setStage(e.target.value || undefined);
}}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
>
<option value="">全部状态</option>
{Object.entries(STAGE_LABEL).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
<Button onClick={load}>刷新</Button>
</Space>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索名称 / 货号"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setQ(v);
}}
/>
<select
value={stage ?? ''}
onChange={(e) => {
setPage(1);
setStage(e.target.value || undefined);
}}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
>
<option value="">全部状态</option>
{Object.entries(STAGE_LABEL).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
<Button onClick={load}>刷新</Button>
</Space>
<Space>
<Button onClick={toggleSelectAll} disabled={items.length === 0}>
{allSelected ? '取消全选' : '全选'}
</Button>
<Popconfirm
title={`确定删除选中的 ${selectedKeys.length} 个商品?`}
disabled={selectedKeys.length === 0}
onConfirm={onBatchDelete}
>
<Button danger disabled={selectedKeys.length === 0}>
删除{selectedKeys.length > 0 ? `(${selectedKeys.length})` : ''}
</Button>
</Popconfirm>
</Space>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: (keys) => setSelectedKeys(keys),
}}
pagination={{
current: page,
pageSize,
+4 -10
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Button, Col, Input, message, Row, Typography } from 'antd';
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';
@@ -137,15 +137,9 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
addonAfter={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{openUrl && (
<Button
size="small"
type="primary"
ghost
icon={<ExportOutlined />}
onClick={() => window.open(openUrl, '_blank', 'noopener')}
>
打开地址
</Button>
<a title="打开采买地址" onClick={() => window.open(openUrl, '_blank', 'noopener')}>
<ExportOutlined />
</a>
)}
<a title="复制采买地址" onClick={copyPurchaseUrl}>
<CopyOutlined />
+4 -5
View File
@@ -31,21 +31,20 @@ 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);
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
// 杀掉生图轮询并丢失勾选/方案状态(表现为「生成中看不到图、上传后页面刷新」)。
// 首屏的加载态由 product 为空时的 Spin 兜底。
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]);
@@ -83,7 +82,7 @@ export default function TrialPage() {
[id, product],
);
if (loading || !product) {
if (!product) {
return (
<div style={{ padding: 80, textAlign: 'center' }}>
<Spin size="large" />
+125 -59
View File
@@ -5,7 +5,7 @@
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popconfirm, Popover, Progress,
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
@@ -145,10 +145,10 @@ function loadWatermark(): WatermarkPayload {
}
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)),
);
// message / modal 用 App 上下文版本:静态方法吃不到 ConfigProvider 动态主题(控制台告警)
const { modal, message } = AntdApp.useApp();
// 素材选择(默认全部不勾选,用户按需勾选参考底图;SKU 规格名供 AI 规划 variant 绑定,与勾选无关)
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(() => new Set());
/** 上传成功待自动勾选的 asset id */
const pendingSelectRef = useRef<Set<string>>(new Set());
const [uploadingCount, setUploadingCount] = useState(0);
@@ -169,6 +169,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const [exportingZip, setExportingZip] = useState(false);
const [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重置后丢弃迟到的过期响应 */
@@ -182,6 +183,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]);
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
// 图名:取 URL 路径末段(采集图为源站文件名;上传图为落盘文件名),无路径时回退规格名
const assetName = (a: ProductAsset): string => {
const raw = a.source_url || a.stored_url || '';
try {
const seg = decodeURIComponent(new URL(raw, window.location.origin).pathname.split('/').pop() || '');
return seg || a.variant_name || '未命名';
} catch {
return a.variant_name || '未命名';
}
};
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
@@ -402,7 +413,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
message.warning('出图方案的张数都是 0');
return;
}
Modal.confirm({
modal.confirm({
title: '生成电商套图',
content: (
<div>
@@ -432,6 +443,38 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}
};
// ── 导出全部生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
const handleExportResult = async () => {
const suiteOk = (suite?.images ?? []).filter((i) => i.status === 'ok');
if (suiteOk.length === 0 && generatedAssets.length === 0) {
message.warning('暂无生图结果可导出');
return;
}
setExportingResult(true);
try {
const images = [
...suiteOk.map((img, i) => ({
url: img.url,
groupName: '套图结果',
variantName: null,
key: `suite-${String(i + 1).padStart(3, '0')}`,
})),
...generatedAssets.map((a, i) => ({
url: assetUrl(a),
groupName: '单张生图',
variantName: a.variant_name,
key: `single-${String(i + 1).padStart(3, '0')}`,
})),
];
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingResult(false);
}
};
const setPlanCount = (idx: number, count: number) => {
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p)));
};
@@ -571,76 +614,89 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Text>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, minmax(0, 1fr))', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id} style={{ width: 112, position: 'relative' }}>
<div key={a.id}>
<div
style={{
position: 'relative',
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 }}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
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>"
/>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
style={{
position: 'absolute',
top: 6,
right: 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: 2,
userSelect: 'none',
}}
>
✓
</span>
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
{/* AI 生图:半透明悬浮在图片右下角 */}
<Button
size="small"
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
style={{
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
background: 'rgba(0, 0, 0, 0.45)',
color: '#fff',
borderColor: 'transparent',
}}
>
AI 生图
</Button>
</div>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
<div
title={a.variant_name || assetName(a)}
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',
fontSize: 11,
color: '#999',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
✓
</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>
{a.variant_name || assetName(a)}
</div>
</div>
);
})}
@@ -854,6 +910,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
水印
</Button>
</Popover>
{/* 导出全部生图结果(套图 + 单张生图) */}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingResult}
disabled={(!suite || !['done', 'partial'].includes(suite.status)) && generatedAssets.length === 0}
onClick={handleExportResult}
>
导出图片
</Button>
</Space>
}
>
@@ -950,9 +1016,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{generatedAssets.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
单张 AI 生图
生图记录
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(采集图片区「AI 生图」的结果 · {generatedAssets.length} 张)
(一键生成与单张生图的落库历史,刷新后仍保留 · {generatedAssets.length} 张)
</Text>
</Text>
<Image.PreviewGroup>
+2 -3
View File
@@ -5,9 +5,8 @@ import { envConfig } from '@/config/env';
const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl,
timeout: 120000, // 图生图较慢,放宽到 120s
headers: {
'Content-Type': 'application/json',
},
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
// 导致 /api/materials/bytes 等表单接口 422(全部字段 missing)。对象请求体 axios 会自动补 JSON 头。
});
apiClient.interceptors.response.use(
+13 -12
View File
@@ -54,21 +54,22 @@ export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
};
/** 生图模型(服务端按模型名路由 provider,同 image-suite-studio) */
/** 生图模型(服务端按模型名路由 provider;同名模型多家中转时 value 用「模型@供应商」区分,label 带供应商名) */
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 输出,适合商业级精修大片' },
{ 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(RightCode)', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip(RightCode)', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
{ value: 'nano-banana', label: 'nano-banana(RightCode)', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
{ value: 'nano-banana-2', label: 'nano-banana-2(RightCode)', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite(RightCode)', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
{ value: 'nano-banana-pro', label: 'nano-banana-pro(RightCode)', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
{ value: 'gpt-image-2@pcore', label: 'gpt-image-2(Pcore)', desc: 'Pcore 中转的 GPT 图像模型,异步任务出图,构图与图内文案渲染强' },
] as const;
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2-vip';
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2@pcore';
export interface WatermarkPayload {
enabled: boolean;
+2 -1
View File
@@ -5,7 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>轻量版</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- 本地固化:cdn.tailwindcss.com 在当前网络下浏览器侧时通时断,断时整页无样式(2026-08-29) -->
<script src="vendor/tailwind.js"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script src="js/tailwind.config.js"></script>
<link rel="stylesheet" href="css/styles.css">
+83
View File
File diff suppressed because one or more lines are too long