feat: 生图增加商品尺寸填报

This commit is contained in:
R524809
2026-09-11 17:28:06 +08:00
parent 27c5daed72
commit dbb2bb80bd
8 changed files with 188 additions and 35 deletions
+27 -5
View File
@@ -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,
+13
View File
@@ -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):
+7 -5
View File
@@ -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
+15 -6
View File
@@ -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, (
f"{p.get('key')}: {p.get('value')}" "; ".join(
for p in (raw.get("params") or [])[:8] f"{p.get('key')}: {p.get('value')}"
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}") for p in (raw.get("params") or [])[:8]
), 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
View File
@@ -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 依赖安装失败,本次只启动后端"
+55 -6
View File
@@ -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,9 +273,19 @@ 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
</Button> checked={generateSizeChart}
onChange={(e) => setGenerateSizeChart(e.target.checked)}
disabled={generating}
style={{ whiteSpace: 'nowrap' }}
>
生成尺寸标注图
</Checkbox>
<Button type="primary" loading={generating} onClick={onGenerate}>
提交任务
</Button>
</div>
</div> </div>
</div> </div>
<Alert <Alert
+38 -3
View File
@@ -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 }}>
生图要求(优先级最高,强制约束,会覆盖其他设定) 生图要求(优先级最高,强制约束,会覆盖其他设定)
+17 -1
View File
@@ -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) 查询进度。 */