diff --git a/docs/v2.1/HANDOFF.md b/docs/v2.1/HANDOFF.md new file mode 100644 index 0000000..b1aef07 --- /dev/null +++ b/docs/v2.1/HANDOFF.md @@ -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 卖家后台 +``` + +技术栈:FastAPI(server,8800)+ React19/Vite/antd6(studio,8900)+ 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/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` 有旧值需手改一次) + +## 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 改动:浏览器实测 + 必要时截图确认 diff --git a/docs/v2.1/README.md b/docs/v2.1/README.md index 2bf3eba..c5122c9 100644 --- a/docs/v2.1/README.md +++ b/docs/v2.1/README.md @@ -107,6 +107,7 @@ V2(原计划) V2.1(现在) | 文档 | 内容 | 什么时候读 | |---|---|---| +| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 | | [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 | | [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 | | [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 | diff --git a/server/api/categories.py b/server/api/categories.py deleted file mode 100644 index b798b29..0000000 --- a/server/api/categories.py +++ /dev/null @@ -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) diff --git a/server/api/collection.py b/server/api/collection.py deleted file mode 100644 index 5449b32..0000000 --- a/server/api/collection.py +++ /dev/null @@ -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)} diff --git a/server/api/materials.py b/server/api/materials.py index d7ec10b..93a1300 100644 --- a/server/api/materials.py +++ b/server/api/materials.py @@ -265,3 +265,36 @@ async def is_collected( ) )).scalars().all() 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_counts(generated 计数由生图回写时累加) + 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} diff --git a/server/api/ozon.py b/server/api/ozon.py deleted file mode 100644 index 9600644..0000000 --- a/server/api/ozon.py +++ /dev/null @@ -1,5 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter(prefix="/api/ozon", tags=["ozon"]) - -# Phase 3: Ozon Seller API product upload diff --git a/server/api/publish.py b/server/api/publish.py deleted file mode 100644 index 88083ed..0000000 --- a/server/api/publish.py +++ /dev/null @@ -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 - ] diff --git a/server/api/shops.py b/server/api/shops.py deleted file mode 100644 index 3f51601..0000000 --- a/server/api/shops.py +++ /dev/null @@ -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} diff --git a/server/api/suite.py b/server/api/suite.py index 618d8c0..c750e95 100644 --- a/server/api/suite.py +++ b/server/api/suite.py @@ -35,7 +35,7 @@ from services.planner import generate_plan from services.prompts import build_context, build_prompt, type_name from services.storage import download_bytes, get_storage, local_path from services.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"]) @@ -246,6 +246,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon asset_id = await append_generated_asset( req.product_id, TaskImage(type_id="custom", name="AI生图", url=url, status="ok"), + after_asset_id=req.after_asset_id, ) except Exception: # noqa: BLE001 pass # 回写失败不影响结果返回,图片已在任务网格可见 diff --git a/server/models/category.py b/server/models/category.py deleted file mode 100644 index 18e7eff..0000000 --- a/server/models/category.py +++ /dev/null @@ -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()) diff --git a/server/models/publish_task.py b/server/models/publish_task.py deleted file mode 100644 index b29becb..0000000 --- a/server/models/publish_task.py +++ /dev/null @@ -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) diff --git a/server/models/shop.py b/server/models/shop.py deleted file mode 100644 index a03edc8..0000000 --- a/server/models/shop.py +++ /dev/null @@ -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() - ) diff --git a/server/schemas/shop.py b/server/schemas/shop.py deleted file mode 100644 index 6f6f762..0000000 --- a/server/schemas/shop.py +++ /dev/null @@ -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 diff --git a/server/schemas/suite.py b/server/schemas/suite.py index d075bbf..ae1493c 100644 --- a/server/schemas/suite.py +++ b/server/schemas/suite.py @@ -153,6 +153,10 @@ class ImageEditSingleRequest(BaseModel): prompt: str = Field(..., min_length=1, description="用户要求(必填,进生图指令)") model: str = Field(default="nano-banana-2") append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)") + after_asset_id: str | None = Field( + default=None, + description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾", + ) class ImageEditSingleResponse(BaseModel): diff --git a/server/services/ozon_client.py b/server/services/ozon_client.py deleted file mode 100644 index c93493d..0000000 --- a/server/services/ozon_client.py +++ /dev/null @@ -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", {}) diff --git a/server/services/publish.py b/server/services/publish.py deleted file mode 100644 index f0194cc..0000000 --- a/server/services/publish.py +++ /dev/null @@ -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 diff --git a/server/services/suite_service.py b/server/services/suite_service.py index 3f745bc..063c82b 100644 --- a/server/services/suite_service.py +++ b/server/services/suite_service.py @@ -46,10 +46,16 @@ def validate_model(provider_name: str, model: str | None) -> None: 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。 - 作为 run_suite 的逐张回调使用(签名须为 (image)),调用方用 partial 绑定 product_id。 + after_asset_id:插入锚点——新素材排在锚点之后(其余素材顺次后移),方便与原图对比; + 缺省/锚点无效时追加到组尾。作为 run_suite 的逐张回调使用时签名须为 (image), + 调用方用 partial 绑定 product_id。 """ 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) async with get_session_factory()() as db: - count = await db.scalar( - select(func.count(ProductAsset.id)).where( - ProductAsset.product_id == pid, - ProductAsset.group_key == "generated", + sort_order: int | None = None + if after_asset_id: + try: + 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( product_id=pid, group_key="generated", variant_name=None, - sort_order=count or 0, + sort_order=sort_order, type="img", source_url="", stored_url=image.url, diff --git a/studio/src/pages/trial/AiImageGenModal.tsx b/studio/src/pages/trial/AiImageGenModal.tsx index 7d61d09..d4295e0 100644 --- a/studio/src/pages/trial/AiImageGenModal.tsx +++ b/studio/src/pages/trial/AiImageGenModal.tsx @@ -9,7 +9,7 @@ interface Props { open: boolean; productId: string; /** 待生成的源图(采集图或生成图) */ - source: { url: string; name: string } | null; + source: { url: string; name: string; afterAssetId?: string } | null; onClose: () => void; /** 生成成功回调(服务端 append 后刷新素材列表) */ onGenerated?: (url: string) => void; @@ -49,9 +49,10 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe prompt: prompt.trim(), model, append: true, + after_asset_id: source.afterAssetId, }); setResultUrl(r.url); - message.success('生成完成,已追加到「生成图」分组'); + message.success('生成完成,已追加到「生成结果」'); onGenerated?.(r.url); } catch (e) { message.error(apiErrorMessage(e)); diff --git a/studio/src/pages/trial/TrialSuitePanel.tsx b/studio/src/pages/trial/TrialSuitePanel.tsx index bf4e540..31c4867 100644 --- a/studio/src/pages/trial/TrialSuitePanel.tsx +++ b/studio/src/pages/trial/TrialSuitePanel.tsx @@ -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, Popover, Progress, + Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popconfirm, Popover, Progress, Radio, Row, Segmented, Select, Space, Tag, Typography, Upload, } from 'antd'; import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons'; @@ -14,7 +14,7 @@ import { apiErrorMessage } from '@/services/api'; import { DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS, STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload, - downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset, + deleteAsset, downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset, type PlanItem, } from '@/services/suite'; import { cleanFilename, downloadBlob } from '@/utils/file'; @@ -174,8 +174,8 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr /** 规划请求序号:重置后丢弃迟到的过期响应 */ const planSeqRef = useRef(0); - // 单张 AI 生图弹窗 - const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null); + // 单张 AI 生图弹窗(afterAssetId:结果插到该素材之后,方便对比) + const [genModal, setGenModal] = useState<{ url: string; name: string; afterAssetId?: string } | null>(null); // 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示 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 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); return ( @@ -947,7 +958,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{generatedAssets.map((a) => ( -
+
+ {/* 删除(二次确认) */} + handleDeleteGenerated(a.id)} + > + 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, + }} + > + ✕ + +
setGenModal({ url: assetUrl(a), name: a.variant_name || 'AI 编辑', afterAssetId: a.id })} > - AI 生图 {a.created_at ? new Date(a.created_at).toLocaleTimeString() : ''} + AI 编辑 + {a.created_at && ( + + {new Date(a.created_at).toLocaleTimeString()} + + )}
))} diff --git a/studio/src/services/suite.ts b/studio/src/services/suite.ts index e867f54..bc1c56d 100644 --- a/studio/src/services/suite.ts +++ b/studio/src/services/suite.ts @@ -161,6 +161,8 @@ export interface ImageEditSinglePayload { prompt: string; model: string; append?: boolean; + /** 插入锚点:新素材排在该素材之后(方便与原图对比) */ + after_asset_id?: string; } /** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */ @@ -168,6 +170,11 @@ export function imageEditSingle(payload: ImageEditSinglePayload) { 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 { title: string; images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;