refactor(server): 移除已放弃的 Ozon API 直传模块,新增素材删除接口

- 删除 shops/publish/categories/ozon 相关路由、模型、schema 与客户端服务(V2.1 决策放弃 API 直传,改人工上传)
- materials 新增 DELETE /assets/{id}:删除素材记录与本地文件,并同步修正 asset_counts
- 试算页支持单张素材删除,生图弹窗交互微调
- 新增 docs/v2.1/HANDOFF.md 工作交接说明,README 补充索引
This commit is contained in:
R524809
2026-08-28 17:47:57 +08:00
parent dc6d38c128
commit a77ec26a02
20 changed files with 248 additions and 1010 deletions
+101
View File
@@ -0,0 +1,101 @@
# 工作交接说明(HANDOFF
> 更新:2026-08-28 晚
> 读者:下一个接手工作的 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)(后端结构)
---
## 1. 项目一句话
Ozon 跨境上品工具链。当前主链路(V2.1,已放弃 Ozon API 直传):
```
extensions/collector 扩展采集(Ozon/1688/淘宝/天猫)
→ 「上报商品」POST /api/materials 入库 + 自动打开试算页
→ studio /trial/{id} 试算页:计价 → 俄文文案 → AI 生图(套图+单张)→ 登记
→ 导出 CSV / 组合码 → 人工上 Ozon 卖家后台
```
技术栈:FastAPIserver8800+ React19/Vite/antd6studio8900+ WXT Chrome 扩展(extensions/collector)。SQLite`data/app.db`),无鉴权(V2.1 决策:先功能后鉴权)。
## 2. 启动与构建
```bash
# server(我最后用的方式;日志在 /tmp/osk-server.log
cd ozon-seller-kit
.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 # 首次
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` 未配(豆包未用)。
## 3. 当前进度
### ✅ 已完成(已提交,git log 有记录)
| 阶段 | 内容 |
|---|---|
| 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` |
### ✅ 已联调验证(真实调用生图 API)
plan / generate(串行队列+轮询+回写 generated/ image-edit(单张,含 `after_asset_id` 插入原图后)/ 水印(文字 Panda Store 右下角)/ export/imagesZIP 分组结构)/ 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` 有旧值需手改一次)
## 4. 下一步(按优先级)
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/` 的清理时机
## 5. 已知坑(新会话必读)
| # | 坑 | 对策 |
|---|---|---|
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 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`(快/便宜) |
## 6. 关键文件速查
| 功能 | 前端(studio/src/ | 后端(server/ |
|---|---|---|
| 试算页骨架 | `pages/trial/TrialPage.tsx` | — |
| 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` |
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`localStorage `trialRegisterRecords` | — |
| 前端服务层 | `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. 验收标准(改完怎么算完成)
1. `pnpm -C studio exec tsc -b` 零错误、`pnpm -C studio build` 通过(提交前还原 `tsconfig.app.tsbuildinfo`
2. 改扩展:`pnpm -C extensions/collector build` 通过
3. 改 server`/api/health` 200、涉及接口 curl/页面冒烟通过
4. UI 改动:浏览器实测 + 必要时截图确认
+1
View File
@@ -107,6 +107,7 @@ V2(原计划) V2.1(现在)
| 文档 | 内容 | 什么时候读 | | 文档 | 内容 | 什么时候读 |
|---|---|---| |---|---|---|
| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 |
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 | | [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 | | [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 | | [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
-121
View File
@@ -1,121 +0,0 @@
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret
from db import get_db
from deps import get_current_user
from models import Shop
from services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/categories", tags=["categories"])
class ShopRef(BaseModel):
shop_id: str
lang: str = "ZH_HANS" # 中文类目
async def _client(shop_id: str, db: AsyncSession) -> OzonClient:
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
def _unwrap(result: dict) -> dict:
return result.get("result", result)
@router.post("/tree")
async def category_tree(
body: ShopRef,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
result = await client.post("/v1/description-category/tree", {"language": body.lang})
return _unwrap(result)
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
class AttributeQuery(BaseModel):
shop_id: str
type_id: int
lang: str = "ZH_HANS"
@router.post("/{category_id}/attributes")
async def category_attributes(
category_id: int,
body: AttributeQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
result = await client.post(
"/v1/description-category/attribute",
{
"description_category_id": category_id,
"type_id": body.type_id,
"language": body.lang,
},
)
return _unwrap(result)
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
class ValueQuery(BaseModel):
shop_id: str
category_id: int
type_id: int
q: str | None = None
limit: int = 100
last_value_id: int | None = None
lang: str = "ZH_HANS"
@router.post("/attribute/{attribute_id}/values")
async def attribute_values(
attribute_id: int,
body: ValueQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
if body.q and len(body.q) >= 2:
result = await client.post(
"/v1/description-category/attribute/values/search",
{
"attribute_id": attribute_id,
"description_category_id": body.category_id,
"type_id": body.type_id,
"limit": body.limit,
"value": body.q,
},
)
else:
result = await client.post(
"/v1/description-category/attribute/values",
{
"attribute_id": attribute_id,
"description_category_id": body.category_id,
"type_id": body.type_id,
"limit": body.limit,
"last_value_id": body.last_value_id or 0,
"language": body.lang,
},
)
return result # values 返回 {result, has_next}
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
-272
View File
@@ -1,272 +0,0 @@
"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。"""
from __future__ import annotations
import re
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db import get_db, get_session_factory
from deps import get_current_user
from models import Product, ProductAsset
from models.enums import AssetStatus, Stage
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
router = APIRouter(prefix="/api", tags=["collection"])
def _parse_number(text: str | None) -> float | None:
"""'1 290 ₽' / '3.5 кг' / '48*18*25' → 1290.0 / 3.5 / 48"""
if not text:
return None
m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", "."))
return float(m.group(1)) if m else None
def _find_param(pairs: list[dict] | None, keys: list[str]) -> str | None:
for p in pairs or []:
k = (p.get("key") or "").lower()
if any(kw in k for kw in keys):
return p.get("value")
return None
def _apply_texts(product: Product, texts: list[TextMaterial]) -> None:
raw = dict(product.raw or {})
raw_texts: list[dict] = list(raw.get("texts") or [])
for t in texts:
raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs})
if t.kind == "title" and t.content and not product.name:
product.name = t.content
raw["title"] = t.content
elif t.kind == "price":
raw["price"] = t.content
num = _parse_number(t.content)
if num is not None and (product.price is None or product.price == 0):
product.price = num
elif t.kind == "params":
raw["params"] = t.pairs
_apply_weight_dims(product, t.pairs)
elif t.kind == "selling_point":
raw["sellingPoints"] = t.content
elif t.kind == "desc":
raw["desc"] = t.content
if not product.description:
product.description = t.content
elif t.kind == "brand":
raw["brand"] = t.content
raw["texts"] = raw_texts
product.raw = raw
def _apply_weight_dims(product: Product, pairs: list[dict] | None) -> None:
"""从参数表里解析「包装重量 / 包装尺寸(长宽高)」,统一换算成克 / 毫米回填。"""
weight = _find_param(pairs, ["包装重量", "重量", "вес"])
if weight is not None:
num = _parse_number(weight)
if num is not None:
is_kg = any(u in weight.lower() for u in ("кг", "kg"))
product.weight = num * 1000 if is_kg else num # 统一为克
product.weight_unit = "g"
l = _find_param(pairs, ["包装长度", "长度", "длина"])
w = _find_param(pairs, ["包装宽度", "宽度", "ширина"])
h = _find_param(pairs, ["包装高度", "高度", "высота"])
if l or w or h:
combined = (l or "") + (w or "") + (h or "")
factor = 1 if any(u in combined.lower() for u in ("мм", "mm")) else 10 # 厘米→毫米
product.depth = (_parse_number(l) or 0) * factor if l else None
product.width = (_parse_number(w) or 0) * factor if w else None
product.height = (_parse_number(h) or 0) * factor if h else None
product.dimension_unit = "mm"
else:
dim = _find_param(pairs, ["包装尺寸", "размер", "габарит", "尺寸"])
if dim is not None:
nums = re.findall(r"\d+(?:[.,]\d+)?", dim.replace(",", "."))
if len(nums) >= 3:
factor = 1 if any(u in dim.lower() for u in ("мм", "mm")) else 10
product.depth = float(nums[0]) * factor
product.width = float(nums[1]) * factor
product.height = float(nums[2]) * factor
product.dimension_unit = "mm"
async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product:
if req.product_id:
product = await db.get(Product, UUID(req.product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product
product = Product(
stage=Stage.collected,
source_platform=req.source.platform,
source_item_id=req.source.itemId,
source_url=req.source.url,
)
db.add(product)
await db.flush()
return product
@router.post("/materials", response_model=MaterialsResponse)
async def create_materials(
req: MaterialsRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
) -> MaterialsResponse:
product = await _get_or_create_product(db, req)
_apply_texts(product, req.texts)
# 采集溯源(追加来源)
if not product.source_url:
product.source_url = req.source.url
if not product.source_platform:
product.source_platform = req.source.platform
# 去重 + 建素材
existing = set()
if req.images:
rows = (await db.execute(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == product.id,
ProductAsset.dedupe_key.isnot(None),
)
)).scalars().all()
existing = {k for k in rows if k}
queued, skipped = 0, 0
for img in req.images:
if img.dedupeKey and img.dedupeKey in existing:
skipped += 1
continue
db.add(ProductAsset(
product_id=product.id,
group_key=img.groupKey,
variant_name=img.variantName,
sort_order=img.index,
type=img.type,
source_url=img.url,
status=AssetStatus.pending,
dedupe_key=img.dedupeKey,
))
if img.dedupeKey:
existing.add(img.dedupeKey)
queued += 1
# 更新分组计数
counts: dict = {}
for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)):
counts[a.group_key] = counts.get(a.group_key, 0) + 1
product.asset_counts = counts
product.stage = Stage.collected if product.stage == Stage.collected else product.stage
await db.commit()
await db.refresh(product)
if queued:
background.add_task(process_product_assets, str(product.id))
return MaterialsResponse(
product_id=str(product.id),
stage=product.stage.value,
assets_queued=queued,
assets_skipped=skipped,
)
async def process_product_assets(product_id: str) -> None:
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
from services.storage import get_storage
storage = get_storage()
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.status == AssetStatus.pending,
)
)).all()
for a in assets:
a.status = AssetStatus.downloading
await db.commit()
try:
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
a.stored_url = stored
a.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
a.status = AssetStatus.failed
a.error = str(exc)[:500]
await db.commit()
@router.post("/materials/bytes")
async def upload_material_bytes(
background: BackgroundTasks,
product_id: str = Form(...),
group_key: str = Form("main"),
variant_name: str | None = Form(None),
sort_order: int = Form(0),
type: str = Form("img"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
data = await file.read()
asset = ProductAsset(
product_id=product.id,
group_key=group_key,
variant_name=variant_name,
sort_order=sort_order,
type=type,
source_url="",
status=AssetStatus.pending,
)
db.add(asset)
await db.flush()
# 直接转存字节
from services.storage import get_storage
storage = get_storage()
try:
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
asset.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
asset.status = AssetStatus.failed
asset.error = str(exc)[:500]
await db.commit()
return {"asset_id": str(asset.id), "status": asset.status.value}
@router.get("/products/{product_id}/fingerprints")
async def product_fingerprints(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.dedupe_key.isnot(None),
)
)).all()
return {"dedupe_keys": list(rows)}
@router.get("/collected")
async def is_collected(
platform: str,
itemId: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.execute(
select(Product).where(
Product.source_platform == platform,
Product.source_item_id == itemId,
)
)).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)}
+33
View File
@@ -265,3 +265,36 @@ async def is_collected(
) )
)).scalars().all() )).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)} return {"collected": len(rows) > 0, "count": len(rows)}
@router.delete("/assets/{asset_id}")
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
from services.storage import local_path
asset = await db.get(ProductAsset, UUID(asset_id))
if asset is None:
raise HTTPException(status_code=404, detail="素材不存在")
if asset.stored_url:
path = local_path(asset.stored_url)
if path is not None and path.is_file():
try:
path.unlink()
except OSError: # noqa: BLE001
pass # 文件删除失败不阻断记录删除
product_id = asset.product_id
group_key = asset.group_key
await db.delete(asset)
# 同步修正 asset_countsgenerated 计数由生图回写时累加)
product = await db.get(Product, product_id)
if product is not None:
counts = dict(product.asset_counts or {})
if group_key in counts:
counts[group_key] = max(0, int(counts.get(group_key) or 0) - 1)
product.asset_counts = counts
await db.commit()
return {"deleted": True}
-5
View File
@@ -1,5 +0,0 @@
from fastapi import APIRouter
router = APIRouter(prefix="/api/ozon", tags=["ozon"])
# Phase 3: Ozon Seller API product upload
-183
View File
@@ -1,183 +0,0 @@
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret
from db import get_db, get_session_factory
from deps import get_current_user
from models import Product, PublishTask, Shop
from models.enums import PublishStatus, Stage
from services.ozon_client import OzonClient, OzonAPIError
from services.publish import build_import_item, validate_ready
router = APIRouter(prefix="/api", tags=["publish"])
class PublishRequest(BaseModel):
shop_id: str
def _client(shop: Shop) -> OzonClient:
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
@router.post("/products/{product_id}/publish")
async def publish_product(
product_id: str,
body: PublishRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
shop = await db.get(Shop, UUID(body.shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
missing = validate_ready(product)
if missing:
raise HTTPException(status_code=422, detail=f"缺少必填项:{''.join(missing)}")
item = build_import_item(product)
client = _client(shop)
try:
result = await client.post("/v3/product/import", {"items": [item]})
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
task_id = (result.get("result") or {}).get("task_id")
if not task_id:
raise HTTPException(status_code=502, detail=f"Ozon 未返回 task_id{result}")
task = PublishTask(
product_id=product.id,
shop_id=shop.id,
ozon_task_id=int(task_id),
status=PublishStatus.pending,
request_payload=item,
)
db.add(task)
product.stage = Stage.publishing
await db.commit()
await db.refresh(task)
background.add_task(_poll, str(task.id))
return {"task_id": str(task.id), "ozon_task_id": task.ozon_task_id}
async def _poll(task_id: str) -> None:
"""后台轮询 import/info,直到 imported / failed 或超时(约 40s)。"""
async with get_session_factory()() as db:
task = await db.get(PublishTask, UUID(task_id))
if task is None:
return
shop = await db.get(Shop, task.shop_id)
product = await db.get(Product, task.product_id)
if shop is None or product is None:
return
client = _client(shop)
for attempt in range(8):
try:
result = await client.post("/v1/product/import/info", {"task_id": task.ozon_task_id})
except OzonAPIError as exc:
task.status = PublishStatus.failed
task.errors = [{"error": exc.detail}]
task.completed_at = datetime.now(timezone.utc)
product.stage = Stage.failed
await db.commit()
return
items = (result.get("result") or {}).get("items") or []
item = items[0] if items else {}
status = item.get("status", "")
product_id = item.get("product_id")
errors = item.get("errors") or []
if status == "imported":
task.status = PublishStatus.imported
task.response = item
task.completed_at = datetime.now(timezone.utc)
if product_id:
product.ozon_product_id = int(product_id)
product.stage = Stage.published
product.published_at = datetime.now(timezone.utc)
await db.commit()
return
if status == "failed":
task.status = PublishStatus.failed
task.errors = errors
task.response = item
task.completed_at = datetime.now(timezone.utc)
product.stage = Stage.failed
await db.commit()
return
# pending / moderation → 继续等
task.status = PublishStatus.moderation if status in ("moderating", "moderation") else PublishStatus.processing
if product_id:
product.ozon_product_id = int(product_id)
await db.commit()
await asyncio.sleep(5 * (attempt + 1))
# 超时未定:保留 processing,前端可刷新
task.status = PublishStatus.moderation
task.response = item
await db.commit()
@router.get("/publish/{task_id}")
async def get_publish_task(
task_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
task = await db.get(PublishTask, UUID(task_id))
if task is None:
raise HTTPException(status_code=404, detail="发布任务不存在")
return {
"id": str(task.id),
"product_id": str(task.product_id),
"shop_id": str(task.shop_id),
"ozon_task_id": task.ozon_task_id,
"status": task.status.value,
"errors": task.errors,
"response": task.response,
"created_at": task.created_at,
"completed_at": task.completed_at,
}
@router.get("/products/{product_id}/publish-history")
async def publish_history(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(PublishTask)
.where(PublishTask.product_id == UUID(product_id))
.order_by(PublishTask.created_at.desc())
)).all()
return [
{
"id": str(t.id),
"ozon_task_id": t.ozon_task_id,
"status": t.status.value,
"errors": t.errors,
"created_at": t.created_at,
"completed_at": t.completed_at,
}
for t in rows
]
-124
View File
@@ -1,124 +0,0 @@
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret, encrypt_secret
from db import get_db
from deps import get_current_user
from models import Shop
from models.enums import ShopStatus
from schemas.shop import ShopCreate, ShopListItem, ShopUpdate
from services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/shops", tags=["shops"])
def _mask(client_id: str) -> str:
return f"{client_id[-4:]}" if len(client_id) > 4 else ""
@router.get("", response_model=list[ShopListItem])
async def list_shops(
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(select(Shop).order_by(Shop.created_at))).all()
items = []
for s in rows:
item = ShopListItem.model_validate(s)
try:
item.client_id_masked = _mask(decrypt_secret(s.client_id_enc))
except Exception: # noqa: BLE001
item.client_id_masked = ""
items.append(item)
return items
@router.post("", response_model=ShopListItem)
async def create_shop(
body: ShopCreate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = Shop(
name=body.name,
client_id_enc=encrypt_secret(body.client_id),
api_key_enc=encrypt_secret(body.api_key),
currency_code=body.currency_code or "RUB",
status=ShopStatus.active,
)
db.add(shop)
await db.commit()
await db.refresh(shop)
item = ShopListItem.model_validate(shop)
item.client_id_masked = _mask(body.client_id)
return item
@router.patch("/{shop_id}", response_model=ShopListItem)
async def update_shop(
shop_id: str,
body: ShopUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
if body.name is not None:
shop.name = body.name
if body.currency_code is not None:
shop.currency_code = body.currency_code
if body.client_id:
shop.client_id_enc = encrypt_secret(body.client_id)
if body.api_key:
shop.api_key_enc = encrypt_secret(body.api_key)
await db.commit()
await db.refresh(shop)
item = ShopListItem.model_validate(shop)
item.client_id_masked = _mask(decrypt_secret(shop.client_id_enc))
return item
@router.delete("/{shop_id}")
async def delete_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
await db.delete(shop)
await db.commit()
return {"deleted": True}
@router.post("/{shop_id}/test")
async def test_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
client = OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
try:
result = await client.test_credentials()
except OzonAPIError as exc:
shop.status = ShopStatus.invalid
await db.commit()
return {"ok": False, "error": exc.detail, "roles": []}
shop.status = ShopStatus.active
shop.last_checked_at = datetime.now(timezone.utc)
await db.commit()
roles = [r.get("name") for r in result.get("roles", [])]
return {"ok": True, "roles": roles}
+2 -1
View File
@@ -35,7 +35,7 @@ from services.planner import generate_plan
from services.prompts import build_context, build_prompt, type_name from services.prompts import build_context, build_prompt, type_name
from services.storage import download_bytes, get_storage, local_path from services.storage import download_bytes, get_storage, local_path
from services.suite_service import append_generated_asset, texts_to_raw, validate_model from services.suite_service import append_generated_asset, texts_to_raw, validate_model
from services.tasks import IMG_OK, create_task, get_task from services.tasks import IMG_OK, TaskImage, create_task, get_task
router = APIRouter(prefix="/api", tags=["suite"]) router = APIRouter(prefix="/api", tags=["suite"])
@@ -246,6 +246,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
asset_id = await append_generated_asset( asset_id = await append_generated_asset(
req.product_id, req.product_id,
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"), TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
after_asset_id=req.after_asset_id,
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass # 回写失败不影响结果返回,图片已在任务网格可见 pass # 回写失败不影响结果返回,图片已在任务网格可见
-60
View File
@@ -1,60 +0,0 @@
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
class CategoryTree(Base):
__tablename__ = "category_tree"
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
parent_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
category_name: Mapped[str] = mapped_column(String(255), default="")
type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
type_name: Mapped[str] = mapped_column(String(255), default="")
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
level: Mapped[int] = mapped_column(Integer, default=0)
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CategoryAttribute(Base):
__tablename__ = "category_attributes"
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
name: Mapped[str] = mapped_column(String(255), default="")
description: Mapped[str] = mapped_column(Text, default="")
type: Mapped[str] = mapped_column(String(32), default="")
dictionary_id: Mapped[int] = mapped_column(BigInteger, default=0)
group_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
group_name: Mapped[str] = mapped_column(String(255), default="")
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
is_aspect: Mapped[bool] = mapped_column(Boolean, default=False)
is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
max_value_count: Mapped[int] = mapped_column(Integer, default=0)
attribute_complex_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
complex_is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
category_dependent: Mapped[bool] = mapped_column(Boolean, default=False)
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AttributeValue(Base):
__tablename__ = "attribute_values"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
value: Mapped[str] = mapped_column(String(512), default="")
picture: Mapped[str] = mapped_column(Text, default="")
info: Mapped[str] = mapped_column(Text, default="")
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-33
View File
@@ -1,33 +0,0 @@
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import PublishStatus
from models.types import JSONType
class PublishTask(Base):
__tablename__ = "publish_tasks"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
)
shop_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("shops.id", ondelete="CASCADE"), index=True
)
ozon_task_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
status: Mapped[PublishStatus] = mapped_column(
Enum(PublishStatus, native_enum=False, length=16), default=PublishStatus.pending, index=True
)
request_payload: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 脱敏后的 items[0]
response: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # import/info 原始结果
errors: Mapped[list | None] = mapped_column(JSONType, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
-30
View File
@@ -1,30 +0,0 @@
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import ShopStatus
class Shop(Base):
__tablename__ = "shops"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 预留多用户
name: Mapped[str] = mapped_column(String(128), nullable=False)
client_id_enc: Mapped[str] = mapped_column(String(1024), nullable=False) # AES-GCM 密文
api_key_enc: Mapped[str] = mapped_column(String(1024), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
status: Mapped[ShopStatus] = mapped_column(
Enum(ShopStatus, native_enum=False, length=16), default=ShopStatus.active
)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
-33
View File
@@ -1,33 +0,0 @@
"""店铺(Ozon 凭证)请求/响应模型。"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class ShopCreate(BaseModel):
name: str
client_id: str
api_key: str
currency_code: str = "CNY"
class ShopUpdate(BaseModel):
name: str | None = None
client_id: str | None = None
api_key: str | None = None
currency_code: str | None = None
class ShopListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
currency_code: str
status: str
client_id_masked: str = "" # 打码尾号
last_checked_at: datetime | None = None
created_at: datetime
+4
View File
@@ -153,6 +153,10 @@ class ImageEditSingleRequest(BaseModel):
prompt: str = Field(..., min_length=1, description="用户要求(必填,进生图指令)") prompt: str = Field(..., min_length=1, description="用户要求(必填,进生图指令)")
model: str = Field(default="nano-banana-2") model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)") append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
default=None,
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
)
class ImageEditSingleResponse(BaseModel): class ImageEditSingleResponse(BaseModel):
-51
View File
@@ -1,51 +0,0 @@
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
from __future__ import annotations
import httpx
OZON_BASE_URL = "https://api-seller.ozon.ru"
class OzonAPIError(Exception):
def __init__(self, status: int, detail: str):
self.status = status
self.detail = detail
super().__init__(f"Ozon API {status}: {detail}")
class OzonClient:
def __init__(self, client_id: str, api_key: str, base_url: str = OZON_BASE_URL):
self.client_id = client_id
self.api_key = api_key
self.base_url = base_url
def _headers(self) -> dict:
return {
"Client-Id": self.client_id,
"Api-Key": self.api_key,
"Content-Type": "application/json",
}
async def post(self, path: str, body: dict | None = None, timeout: float = 60.0) -> dict:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.post(f"{self.base_url}{path}", headers=self._headers(), json=body or {})
if resp.status_code >= 400:
raise OzonAPIError(resp.status_code, resp.text[:500])
try:
return resp.json()
except Exception: # noqa: BLE001
return {}
async def get(self, path: str, timeout: float = 60.0) -> dict:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.get(f"{self.base_url}{path}", headers=self._headers())
if resp.status_code >= 400:
raise OzonAPIError(resp.status_code, resp.text[:500])
try:
return resp.json()
except Exception: # noqa: BLE001
return {}
async def test_credentials(self) -> dict:
"""调 /v1/roles 校验凭证与权限范围。"""
return await self.post("/v1/roles", {})
-79
View File
@@ -1,79 +0,0 @@
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
from __future__ import annotations
from models import Product
def _fmt(v) -> str:
if v is None:
return ""
return str(v)
def validate_ready(product: Product) -> list[str]:
"""返回缺失/非法必填项的中文提示列表;空列表表示可发布。"""
missing: list[str] = []
if not product.offer_id.strip():
missing.append("货号 offer_id")
if not product.name.strip():
missing.append("商品名 name")
if not product.description.strip():
missing.append("描述 description")
if not product.description_category_id:
missing.append("类目 description_category_id")
if product.price is None or product.price <= 0:
missing.append("售价 price")
if not product.weight or product.weight <= 0:
missing.append("重量 weight")
for label, val in (("长 depth", product.depth), ("宽 width", product.width), ("高 height", product.height)):
if not val or val <= 0:
missing.append(label)
if not product.images:
missing.append("主图 images(至少 1 张)")
elif any(u and u.startswith("http://") for u in product.images):
missing.append("图片链接必须使用 https(Ozon 不接受 http 直链)")
return missing
def _with_model_name(product: Product) -> list:
"""把 raw.model_name 自动注入为 attribute 9048(型号名称),用于多变体合并。"""
attrs = list(product.attributes or [])
model_name = (product.raw or {}).get("model_name") if product.raw else None
if not model_name:
return attrs
# 已手动映射 9048 就不重复添加
for a in attrs:
if isinstance(a, dict) and a.get("id") == 9048:
return attrs
attrs.append({"complex_id": 0, "id": 9048, "values": [{"value": model_name}]})
return attrs
def build_import_item(product: Product) -> dict:
item: dict = {
"offer_id": product.offer_id,
"name": product.name,
"description": product.description,
"description_category_id": product.description_category_id,
"price": _fmt(product.price),
"old_price": _fmt(product.old_price),
"currency_code": product.currency_code or "CNY",
"vat": product.vat or "0",
"depth": product.depth,
"width": product.width,
"height": product.height,
"dimension_unit": product.dimension_unit or "mm",
"weight": product.weight,
"weight_unit": product.weight_unit or "g",
"images": product.images or [],
"primary_image": product.primary_image or "",
"images360": product.images360 or [],
"color_image": product.color_image or "",
"attributes": _with_model_name(product),
"complex_attributes": product.complex_attributes or [],
}
if product.type_id:
item["type_id"] = product.type_id
if product.barcode:
item["barcode"] = product.barcode
return item
+38 -8
View File
@@ -46,10 +46,16 @@ def validate_model(provider_name: str, model: str | None) -> None:
raise ValueError(f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}") raise ValueError(f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}")
async def append_generated_asset(product_id: str, image: TaskImage) -> str | None: async def append_generated_asset(
product_id: str,
image: TaskImage,
after_asset_id: str | None = None,
) -> str | None:
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。 """把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。
作为 run_suite 的逐张回调使用(签名须为 (image)),调用方用 partial 绑定 product_id。 after_asset_id:插入锚点——新素材排在锚点之后(其余素材顺次后移),方便与原图对比;
缺省/锚点无效时追加到组尾。作为 run_suite 的逐张回调使用时签名须为 (image),
调用方用 partial 绑定 product_id。
""" """
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -58,17 +64,41 @@ async def append_generated_asset(product_id: str, image: TaskImage) -> str | Non
pid = uuid.UUID(product_id) pid = uuid.UUID(product_id)
async with get_session_factory()() as db: async with get_session_factory()() as db:
count = await db.scalar( sort_order: int | None = None
select(func.count(ProductAsset.id)).where( if after_asset_id:
ProductAsset.product_id == pid, try:
ProductAsset.group_key == "generated", anchor = await db.get(ProductAsset, UUID(after_asset_id))
except ValueError:
anchor = None
if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated":
# 锚点之后的素材顺次后移,腾出插入位
followers = (
await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == pid,
ProductAsset.group_key == "generated",
ProductAsset.sort_order > anchor.sort_order,
)
)
).scalars().all()
for follower in followers:
follower.sort_order += 1
sort_order = anchor.sort_order + 1
if sort_order is None:
sort_order = await db.scalar(
select(func.coalesce(func.max(ProductAsset.sort_order), -1)).where(
ProductAsset.product_id == pid,
ProductAsset.group_key == "generated",
)
) )
) sort_order = (sort_order or 0) + 1
asset = ProductAsset( asset = ProductAsset(
product_id=pid, product_id=pid,
group_key="generated", group_key="generated",
variant_name=None, variant_name=None,
sort_order=count or 0, sort_order=sort_order,
type="img", type="img",
source_url="", source_url="",
stored_url=image.url, stored_url=image.url,
+3 -2
View File
@@ -9,7 +9,7 @@ interface Props {
open: boolean; open: boolean;
productId: string; productId: string;
/** 待生成的源图(采集图或生成图) */ /** 待生成的源图(采集图或生成图) */
source: { url: string; name: string } | null; source: { url: string; name: string; afterAssetId?: string } | null;
onClose: () => void; onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */ /** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void; onGenerated?: (url: string) => void;
@@ -49,9 +49,10 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
prompt: prompt.trim(), prompt: prompt.trim(),
model, model,
append: true, append: true,
after_asset_id: source.afterAssetId,
}); });
setResultUrl(r.url); setResultUrl(r.url);
message.success('生成完成,已追加到「生成图」分组'); message.success('生成完成,已追加到「生成结果」');
onGenerated?.(r.url); onGenerated?.(r.url);
} catch (e) { } catch (e) {
message.error(apiErrorMessage(e)); message.error(apiErrorMessage(e));
+59 -8
View File
@@ -5,7 +5,7 @@
*/ */
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { import {
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress, Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popconfirm, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload, Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd'; } from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons'; import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
@@ -14,7 +14,7 @@ import { apiErrorMessage } from '@/services/api';
import { import {
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS, DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload, STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset, deleteAsset, downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
type PlanItem, type PlanItem,
} from '@/services/suite'; } from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file'; import { cleanFilename, downloadBlob } from '@/utils/file';
@@ -174,8 +174,8 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
/** 规划请求序号:重置后丢弃迟到的过期响应 */ /** 规划请求序号:重置后丢弃迟到的过期响应 */
const planSeqRef = useRef(0); const planSeqRef = useRef(0);
// 单张 AI 生图弹窗 // 单张 AI 生图弹窗afterAssetId:结果插到该素材之后,方便对比)
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null); const [genModal, setGenModal] = useState<{ url: string; name: string; afterAssetId?: string } | null>(null);
// 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示 // 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]); const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]);
@@ -510,6 +510,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
); );
const selectableCount = imgs.length; const selectableCount = imgs.length;
/** 删除一张已生成的图片(二次确认后调用),成功后刷新素材列表 */
const handleDeleteGenerated = async (assetId: string) => {
try {
await deleteAsset(assetId);
message.success('已删除');
onRefreshAssets();
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0); const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
return ( return (
@@ -947,7 +958,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
<Image.PreviewGroup> <Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
{generatedAssets.map((a) => ( {generatedAssets.map((a) => (
<div key={a.id} style={{ width: 112 }}> <div key={a.id} style={{ width: 100, position: 'relative' }}>
<Image <Image
src={assetUrl(a)} src={assetUrl(a)}
width={100} width={100}
@@ -955,18 +966,58 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
style={{ objectFit: 'cover', borderRadius: 6 }} style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>" fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
/> />
{/* 删除(二次确认) */}
<Popconfirm
title="删除图片"
description="确认删除这张已生成的图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteGenerated(a.id)}
>
<span
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: 'rgba(255,255,255,0.95)',
color: '#ff4d4f',
border: '1px solid #ffccc7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 1,
userSelect: 'none',
lineHeight: 1,
}}
>
</span>
</Popconfirm>
<div <div
style={{ style={{
fontSize: 11, fontSize: 11,
color: 'rgba(0,0,0,0.45)',
marginTop: 2, marginTop: 2,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
title={a.stored_url ?? undefined} title="基于这张图再次 AI 编辑"
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || 'AI 编辑', afterAssetId: a.id })}
> >
AI {a.created_at ? new Date(a.created_at).toLocaleTimeString() : ''} <a style={{ color: '#1677ff' }}>AI </a>
{a.created_at && (
<span style={{ color: 'rgba(0,0,0,0.45)', marginLeft: 4 }}>
{new Date(a.created_at).toLocaleTimeString()}
</span>
)}
</div> </div>
</div> </div>
))} ))}
+7
View File
@@ -161,6 +161,8 @@ export interface ImageEditSinglePayload {
prompt: string; prompt: string;
model: string; model: string;
append?: boolean; append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string;
} }
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */ /** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
@@ -168,6 +170,11 @@ export function imageEditSingle(payload: ImageEditSinglePayload) {
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload); return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
} }
/** 删除一条素材(DB 记录 + 本地文件),用于删除已生成的图片 */
export function deleteAsset(assetId: string) {
return api.delete<{ deleted: boolean }>(`/assets/${assetId}`);
}
export interface ExportImagesPayload { export interface ExportImagesPayload {
title: string; title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>; images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;