feat: 生图增加商品尺寸填报
This commit is contained in:
+27
-5
@@ -50,6 +50,27 @@ from services.suite_service import texts_to_raw, validate_model
|
|||||||
router = APIRouter(prefix="/api", tags=["suite"])
|
router = APIRouter(prefix="/api", tags=["suite"])
|
||||||
|
|
||||||
|
|
||||||
|
def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
|
||||||
|
"""将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。"""
|
||||||
|
if dimensions is None:
|
||||||
|
if required:
|
||||||
|
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
|
||||||
|
return raw
|
||||||
|
values = (dimensions.length, dimensions.width, dimensions.height)
|
||||||
|
if not all(value is not None and value > 0 for value in values):
|
||||||
|
if required:
|
||||||
|
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
|
||||||
|
return raw
|
||||||
|
enriched = dict(raw)
|
||||||
|
enriched["dimensions"] = {
|
||||||
|
"length": dimensions.length,
|
||||||
|
"width": dimensions.width,
|
||||||
|
"height": dimensions.height,
|
||||||
|
"unit": dimensions.unit,
|
||||||
|
}
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
||||||
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
|
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
|
||||||
try:
|
try:
|
||||||
@@ -64,7 +85,7 @@ def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iter
|
|||||||
@router.post("/suite/plan", response_model=PlanResponse)
|
@router.post("/suite/plan", response_model=PlanResponse)
|
||||||
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
|
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
|
||||||
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
|
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
|
||||||
product_info = texts_to_raw(req.texts)
|
product_info = _with_dimensions(texts_to_raw(req.texts), req.dimensions)
|
||||||
if not product_info.get("title"):
|
if not product_info.get("title"):
|
||||||
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
||||||
try:
|
try:
|
||||||
@@ -140,7 +161,7 @@ async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
|
|||||||
ratio=spec["ratio"],
|
ratio=spec["ratio"],
|
||||||
style_set=req.style_set,
|
style_set=req.style_set,
|
||||||
request_payload={
|
request_payload={
|
||||||
"context": texts_to_raw(req.texts),
|
"context": _with_dimensions(texts_to_raw(req.texts), req.dimensions),
|
||||||
"style_prompt": req.style_prompt,
|
"style_prompt": req.style_prompt,
|
||||||
"requirements": req.requirements,
|
"requirements": req.requirements,
|
||||||
"watermark": req.watermark.model_dump() if req.watermark else None,
|
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||||
@@ -295,6 +316,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
|||||||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||||
|
|
||||||
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||||
|
is_size_chart = bool(req.generate_size_chart)
|
||||||
task = await create_image_job(
|
task = await create_image_job(
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
kind="image_edit",
|
kind="image_edit",
|
||||||
@@ -306,7 +328,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
|||||||
ratio=spec["ratio"],
|
ratio=spec["ratio"],
|
||||||
style_set=5,
|
style_set=5,
|
||||||
request_payload={
|
request_payload={
|
||||||
"context": {"title": "AI 精修"},
|
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
|
||||||
"style_prompt": None,
|
"style_prompt": None,
|
||||||
"requirements": req.prompt,
|
"requirements": req.prompt,
|
||||||
"watermark": req.watermark.model_dump() if req.watermark else None,
|
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||||
@@ -316,8 +338,8 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
|||||||
},
|
},
|
||||||
item_payloads=[{
|
item_payloads=[{
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"kind": "custom",
|
"kind": "size_chart" if is_size_chart else "custom",
|
||||||
"title": "AI生图",
|
"title": "尺寸标注图" if is_size_chart else "AI生图",
|
||||||
"detail": "",
|
"detail": "",
|
||||||
"prompt_hint": "",
|
"prompt_hint": "",
|
||||||
"variant_name": None,
|
"variant_name": None,
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ class WatermarkOptions(BaseModel):
|
|||||||
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
|
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductDimensions(BaseModel):
|
||||||
|
"""商品长宽高;尺寸图只会使用完整的三项数据。"""
|
||||||
|
|
||||||
|
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
|
||||||
|
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
|
||||||
|
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
|
||||||
|
unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位")
|
||||||
|
|
||||||
|
|
||||||
class PlanItem(BaseModel):
|
class PlanItem(BaseModel):
|
||||||
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
||||||
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
||||||
@@ -94,6 +103,7 @@ class SuitePlanRequest(BaseModel):
|
|||||||
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
|
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
|
||||||
platform: str = Field(default="ozon")
|
platform: str = Field(default="ozon")
|
||||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
|
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
|
||||||
|
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于规划尺寸标注图")
|
||||||
|
|
||||||
|
|
||||||
class PlanItemOut(BaseModel):
|
class PlanItemOut(BaseModel):
|
||||||
@@ -129,6 +139,7 @@ class SuiteGenerateRequest(BaseModel):
|
|||||||
platform: str = Field(default="ozon")
|
platform: str = Field(default="ozon")
|
||||||
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
|
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
|
||||||
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
|
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
|
||||||
|
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
|
||||||
|
|
||||||
|
|
||||||
class SuiteCreateResponse(BaseModel):
|
class SuiteCreateResponse(BaseModel):
|
||||||
@@ -177,6 +188,8 @@ class ImageEditSingleRequest(BaseModel):
|
|||||||
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
||||||
)
|
)
|
||||||
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
|
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
|
||||||
|
generate_size_chart: bool = Field(default=False, description="是否按尺寸标注图生成")
|
||||||
|
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
|
||||||
|
|
||||||
|
|
||||||
class ImageEditTaskResponse(BaseModel):
|
class ImageEditTaskResponse(BaseModel):
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ ALLOWED_KINDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
|
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
|
||||||
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
|
# AI 智能规划最多生成 8 张;即使用户在要求中指定更高张数也不突破此上限。
|
||||||
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
|
PLAN_SAFETY_MAX = 8
|
||||||
PLAN_SAFETY_MAX = 15
|
PLAN_REQUIREMENTS_MAX = 8
|
||||||
PLAN_REQUIREMENTS_MAX = 24
|
|
||||||
MAX_VARIANT_MAIN_IMAGES = 2
|
MAX_VARIANT_MAIN_IMAGES = 2
|
||||||
|
|
||||||
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
|
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
|
||||||
@@ -53,7 +52,7 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
|
|||||||
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
||||||
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
||||||
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
||||||
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
|
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,最多 8 张;若「生图要求」明确指定了张数,按指定张数输出但仍不得超过 8 张。
|
||||||
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
||||||
|
|
||||||
## 规划规则
|
## 规划规则
|
||||||
@@ -126,6 +125,9 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
|||||||
|
|
||||||
def _has_dimensions(product_info: dict) -> bool:
|
def _has_dimensions(product_info: dict) -> bool:
|
||||||
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
|
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
|
||||||
|
dimensions = product_info.get("dimensions") or {}
|
||||||
|
if isinstance(dimensions, dict) and all(dimensions.get(key) is not None for key in ("length", "width", "height")):
|
||||||
|
return True
|
||||||
for param in product_info.get("params") or []:
|
for param in product_info.get("params") or []:
|
||||||
if not isinstance(param, dict):
|
if not isinstance(param, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
|
|||||||
if len(selling_points) >= 5:
|
if len(selling_points) >= 5:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
dimensions = raw.get("dimensions") or {}
|
||||||
|
dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else ()
|
||||||
|
dimensions_line = ""
|
||||||
|
if len(dimension_values) == 3 and all(value is not None for value in dimension_values):
|
||||||
|
unit = dimensions.get("unit") or "mm"
|
||||||
|
dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}"
|
||||||
params_line = "; ".join(
|
params_line = "; ".join(
|
||||||
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
||||||
)
|
)
|
||||||
@@ -141,13 +147,16 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
|
|||||||
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
||||||
"desc": desc,
|
"desc": desc,
|
||||||
"selling_points": selling_points[:3],
|
"selling_points": selling_points[:3],
|
||||||
"params_line": params_line,
|
"params_line": "; ".join(filter(None, (params_line, dimensions_line))),
|
||||||
# 俄文图片里不能直接渲染中文参数;保留不含汉字的规格(型号、数字、单位)供尺寸图使用。
|
# 俄文图片里不能直接渲染中文参数;保留不含汉字的规格(型号、数字、单位)供尺寸图使用。
|
||||||
"params_line_non_cjk": "; ".join(
|
"params_line_non_cjk": "; ".join(filter(None, (
|
||||||
|
"; ".join(
|
||||||
f"{p.get('key')}: {p.get('value')}"
|
f"{p.get('key')}: {p.get('value')}"
|
||||||
for p in (raw.get("params") or [])[:8]
|
for p in (raw.get("params") or [])[:8]
|
||||||
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
|
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
|
||||||
),
|
),
|
||||||
|
dimensions_line,
|
||||||
|
))),
|
||||||
"price": raw.get("price") or "",
|
"price": raw.get("price") or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-9
@@ -9,6 +9,13 @@ BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}"
|
|||||||
STUDIO_URL="http://localhost:8900"
|
STUDIO_URL="http://localhost:8900"
|
||||||
RUNTIME_DIR="$PROJECT_DIR/data/runtime"
|
RUNTIME_DIR="$PROJECT_DIR/data/runtime"
|
||||||
INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid"
|
INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid"
|
||||||
|
# 两个前端项目均锁定 pnpm 10.32.1。系统的 `pnpm` 是 Corepack shim,
|
||||||
|
# 其默认版本可能随 Node 更新而变化,因此显式指定项目所需的版本。
|
||||||
|
PNPM_VERSION="10.32.1"
|
||||||
|
|
||||||
|
project_pnpm() {
|
||||||
|
corepack "pnpm@${PNPM_VERSION}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
# 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束,
|
# 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束,
|
||||||
# 不扫描或误杀其他项目的 Python / Node 进程。
|
# 不扫描或误杀其他项目的 Python / Node 进程。
|
||||||
@@ -141,7 +148,7 @@ watch_collector() {
|
|||||||
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
|
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
|
||||||
touch "$COLLECTOR_STAMP"
|
touch "$COLLECTOR_STAMP"
|
||||||
echo "🔧 检测到插件代码改动,重新构建…"
|
echo "🔧 检测到插件代码改动,重新构建…"
|
||||||
if pnpm -C extensions/collector build >/dev/null 2>&1; then
|
if project_pnpm -C extensions/collector build >/dev/null 2>&1; then
|
||||||
echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效"
|
echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效"
|
||||||
else
|
else
|
||||||
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
|
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
|
||||||
@@ -152,13 +159,13 @@ watch_collector() {
|
|||||||
|
|
||||||
if [[ ! -d extensions/collector ]]; then
|
if [[ ! -d extensions/collector ]]; then
|
||||||
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
|
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
|
||||||
elif ! command -v pnpm >/dev/null 2>&1; then
|
elif ! command -v corepack >/dev/null 2>&1; then
|
||||||
echo "⚠️ 未找到 pnpm,本次跳过采集插件构建(可手动:pnpm -C extensions/collector build)"
|
echo "⚠️ 未找到 Corepack,本次跳过采集插件构建(可手动:corepack pnpm@${PNPM_VERSION} -C extensions/collector build)"
|
||||||
elif ! CI=true pnpm -C extensions/collector install --silent; then
|
elif ! CI=true project_pnpm -C extensions/collector install --silent; then
|
||||||
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
|
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
|
||||||
else
|
else
|
||||||
echo "正在构建采集插件…"
|
echo "正在构建采集插件…"
|
||||||
if pnpm -C extensions/collector build; then
|
if project_pnpm -C extensions/collector build; then
|
||||||
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
|
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
|
||||||
else
|
else
|
||||||
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
|
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
|
||||||
@@ -182,11 +189,11 @@ supervise_worker &
|
|||||||
WORKER_SUPERVISOR_PID=$!
|
WORKER_SUPERVISOR_PID=$!
|
||||||
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
|
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
|
||||||
|
|
||||||
if ! command -v pnpm >/dev/null 2>&1; then
|
if ! command -v corepack >/dev/null 2>&1; then
|
||||||
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
|
echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)"
|
||||||
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
|
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
|
||||||
elif CI=true pnpm -C studio install --silent; then
|
elif CI=true project_pnpm -C studio install --silent; then
|
||||||
pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器
|
project_pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器
|
||||||
STUDIO_PID=$!
|
STUDIO_PID=$!
|
||||||
else
|
else
|
||||||
echo "⚠️ studio 依赖安装失败,本次只启动后端"
|
echo "⚠️ studio 依赖安装失败,本次只启动后端"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
|
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Select, Space, Typography } from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorMessage } from '@/services/api';
|
||||||
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, WatermarkPayload } from '@/services/suite';
|
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ interface Props {
|
|||||||
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
|
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||||
|
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
|
||||||
|
const [generateSizeChart, setGenerateSizeChart] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [resultUrl, setResultUrl] = useState('');
|
const [resultUrl, setResultUrl] = useState('');
|
||||||
const [taskId, setTaskId] = useState('');
|
const [taskId, setTaskId] = useState('');
|
||||||
@@ -48,6 +50,8 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !activeTaskRef.current) {
|
if (open && !activeTaskRef.current) {
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
|
setDimensions({ unit: 'mm' });
|
||||||
|
setGenerateSizeChart(false);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
setTaskId('');
|
setTaskId('');
|
||||||
@@ -126,6 +130,12 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
const hasCompleteDimensions = [dimensions.length, dimensions.width, dimensions.height]
|
||||||
|
.every((value) => typeof value === 'number' && value > 0);
|
||||||
|
if (generateSizeChart && !hasCompleteDimensions) {
|
||||||
|
message.warning('生成尺寸标注图前,请完整填写商品长、宽、高');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
setTaskError('');
|
setTaskError('');
|
||||||
@@ -138,6 +148,8 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
append: true,
|
append: true,
|
||||||
after_asset_id: source.afterAssetId,
|
after_asset_id: source.afterAssetId,
|
||||||
watermark: watermark?.enabled ? watermark : undefined,
|
watermark: watermark?.enabled ? watermark : undefined,
|
||||||
|
generate_size_chart: generateSizeChart,
|
||||||
|
dimensions: hasCompleteDimensions ? dimensions : null,
|
||||||
});
|
});
|
||||||
setTaskId(r.suite_id);
|
setTaskId(r.suite_id);
|
||||||
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
||||||
@@ -209,6 +221,33 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{(['length', 'width', 'height'] as const).map((key, index) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
controls={false}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={dimensions[key]}
|
||||||
|
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
|
||||||
|
placeholder={['长', '宽', '高'][index]}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Select
|
||||||
|
style={{ width: 76, flexShrink: 0 }}
|
||||||
|
value={dimensions.unit ?? 'mm'}
|
||||||
|
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
|
||||||
|
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={5}
|
rows={5}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
@@ -234,11 +273,21 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={generateSizeChart}
|
||||||
|
onChange={(e) => setGenerateSizeChart(e.target.checked)}
|
||||||
|
disabled={generating}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
生成尺寸标注图
|
||||||
|
</Checkbox>
|
||||||
|
<Button type="primary" loading={generating} onClick={onGenerate}>
|
||||||
提交任务
|
提交任务
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Alert
|
<Alert
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
type={taskError && !generating ? 'error' : 'info'}
|
type={taskError && !generating ? 'error' : 'info'}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
||||||
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
|
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress,
|
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, InputNumber, Popconfirm, Popover, Progress,
|
||||||
Row, Segmented, Select, Space, Tag, Typography, Upload,
|
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';
|
||||||
@@ -15,7 +15,7 @@ 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,
|
||||||
applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset,
|
applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset,
|
||||||
type PlanItem,
|
type PlanItem, type ProductDimensions,
|
||||||
} from '@/services/suite';
|
} from '@/services/suite';
|
||||||
import { cleanFilename, downloadBlob } from '@/utils/file';
|
import { cleanFilename, downloadBlob } from '@/utils/file';
|
||||||
import AiImageGenModal from './AiImageGenModal';
|
import AiImageGenModal from './AiImageGenModal';
|
||||||
@@ -28,6 +28,9 @@ const { Text } = Typography;
|
|||||||
|
|
||||||
const WATERMARK_STORAGE_KEY = 'trialWatermark';
|
const WATERMARK_STORAGE_KEY = 'trialWatermark';
|
||||||
|
|
||||||
|
const hasCompleteDimensions = (dimensions: ProductDimensions) =>
|
||||||
|
[dimensions.length, dimensions.width, dimensions.height].every((value) => typeof value === 'number' && value > 0);
|
||||||
|
|
||||||
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
|
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
|
||||||
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||||
const [text, setText] = useState(String(value));
|
const [text, setText] = useState(String(value));
|
||||||
@@ -158,6 +161,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
const [styleSet, setStyleSet] = useState(1);
|
const [styleSet, setStyleSet] = useState(1);
|
||||||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||||
const [requirements, setRequirements] = useState('');
|
const [requirements, setRequirements] = useState('');
|
||||||
|
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
|
||||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||||
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
|
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
|
||||||
|
|
||||||
@@ -531,6 +535,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
message.warning('出图方案的张数都是 0');
|
message.warning('出图方案的张数都是 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activePlan.some((item) => item.kind === 'size_chart') && !hasCompleteDimensions(dimensions)) {
|
||||||
|
message.warning('方案包含尺寸标注图,请先在右侧完整填写商品长、宽、高及单位,或将该项数量设为 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setSuite(null);
|
setSuite(null);
|
||||||
try {
|
try {
|
||||||
@@ -543,6 +551,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
style_set: styleSet,
|
style_set: styleSet,
|
||||||
style_prompt: currentStylePrompt || null,
|
style_prompt: currentStylePrompt || null,
|
||||||
requirements: requirements.trim() || null,
|
requirements: requirements.trim() || null,
|
||||||
|
dimensions: hasCompleteDimensions(dimensions) ? dimensions : null,
|
||||||
plan: activePlan,
|
plan: activePlan,
|
||||||
platform: 'ozon',
|
platform: 'ozon',
|
||||||
model,
|
model,
|
||||||
@@ -641,6 +650,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
image_stats: stats,
|
image_stats: stats,
|
||||||
platform: 'ozon',
|
platform: 'ozon',
|
||||||
requirements: requirements.trim() || null,
|
requirements: requirements.trim() || null,
|
||||||
|
dimensions: hasCompleteDimensions(dimensions) ? dimensions : null,
|
||||||
});
|
});
|
||||||
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
|
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
|
||||||
setPlan(data.items);
|
setPlan(data.items);
|
||||||
@@ -1133,6 +1143,31 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
|
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>商品尺寸(长 × 宽 × 高)</Text>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
||||||
|
{(['length', 'width', 'height'] as const).map((key, index) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
controls={false}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={dimensions[key]}
|
||||||
|
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
|
||||||
|
placeholder={['长', '宽', '高'][index]}
|
||||||
|
/>
|
||||||
|
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Select
|
||||||
|
style={{ width: 76, flexShrink: 0 }}
|
||||||
|
value={dimensions.unit ?? 'mm'}
|
||||||
|
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
|
||||||
|
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export interface PlanItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
|
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
|
||||||
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) => ({
|
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS
|
||||||
|
.filter((t) => t.value !== 'multi_scene')
|
||||||
|
.slice(0, 7)
|
||||||
|
.map((t) => ({
|
||||||
kind: t.value,
|
kind: t.value,
|
||||||
title: t.label,
|
title: t.label,
|
||||||
detail: '',
|
detail: '',
|
||||||
@@ -41,6 +44,14 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) =
|
|||||||
variant_name: null,
|
variant_name: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** 商品外廓尺寸;单位可选 mm 或 cm。 */
|
||||||
|
export interface ProductDimensions {
|
||||||
|
length?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
unit?: 'mm' | 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
|
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
|
||||||
export const STYLE_SET_OPTIONS = [
|
export const STYLE_SET_OPTIONS = [
|
||||||
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
|
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
|
||||||
@@ -100,6 +111,7 @@ export interface SuitePlanPayload {
|
|||||||
image_stats: Record<string, number>;
|
image_stats: Record<string, number>;
|
||||||
platform: string;
|
platform: string;
|
||||||
requirements?: string | null;
|
requirements?: string | null;
|
||||||
|
dimensions?: ProductDimensions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuiteGeneratePayload {
|
export interface SuiteGeneratePayload {
|
||||||
@@ -113,6 +125,7 @@ export interface SuiteGeneratePayload {
|
|||||||
platform: string;
|
platform: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
watermark?: WatermarkPayload;
|
watermark?: WatermarkPayload;
|
||||||
|
dimensions?: ProductDimensions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuiteImageInfo {
|
export interface SuiteImageInfo {
|
||||||
@@ -193,6 +206,9 @@ export interface ImageEditSinglePayload {
|
|||||||
after_asset_id?: string;
|
after_asset_id?: string;
|
||||||
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
|
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
|
||||||
watermark?: WatermarkPayload;
|
watermark?: WatermarkPayload;
|
||||||
|
/** 勾选时将当前图按尺寸标注图任务处理。 */
|
||||||
|
generate_size_chart?: boolean;
|
||||||
|
dimensions?: ProductDimensions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
|
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
|
||||||
|
|||||||
Reference in New Issue
Block a user