feat: AI生图功能丰富

This commit is contained in:
2026-09-27 22:41:33 +08:00
parent 1c4fe1a836
commit 7f1503294f
20 changed files with 627 additions and 336 deletions
+15 -2
View File
@@ -22,7 +22,6 @@ interface Props {
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
const [models, setModels] = useState<AiModelOption[]>([]);
const [model, setModel] = useState<string>('');
const [sourceText, setSourceText] = useState('');
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<CopyResponse | null>(null);
@@ -41,8 +40,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
return parts.join('\n\n');
}, [raw]);
// 文案模块的“采集信息”是可人工整理的 AI 输入。优先恢复用户上次保存的内容;
// 只有从未编辑过时才从当前采集数据自动拼出默认值。
const savedSourceText = typeof raw.copy_source_text === 'string' ? raw.copy_source_text : null;
const [sourceText, setSourceText] = useState(() => savedSourceText ?? defaultSource);
useEffect(() => {
if (!sourceText && defaultSource) setSourceText(defaultSource);
getAiModels()
.then((r) => {
setModels(r.models);
@@ -52,6 +55,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 与价格试算一致:用户完成输入(失焦)后将编辑后的 AI 输入落到当前商品。 */
const saveSourceText = async () => {
if (sourceText === savedSourceText) return;
await onSave({ raw: { ...raw, copy_source_text: sourceText } });
};
const onGenerate = async () => {
if (sourceText.trim().length < 10) {
message.warning('请先粘贴至少 10 字的商品资料');
@@ -115,8 +124,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
rows={16}
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
onBlur={() => { void saveSourceText(); }}
placeholder="采集的商品资料(可编辑后生成)"
/>
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 12 }}>
修改后失去焦点会自动保存
</Text>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Select
style={{ width: 180 }}
@@ -0,0 +1,40 @@
.single-ai-drawer .ant-drawer-body { padding: 18px 22px; background: #f8f9fb; }
.single-ai-drawer .ant-drawer-footer { padding: 12px 22px; }
.single-ai-title { display: flex; gap: 12px; align-items: baseline; font-weight: 650; }
.single-ai-title .ant-typography { font-size: 12px; font-weight: normal; }
.single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(310px, .82fr); gap: 18px; align-items: start; }
.single-ai-media, .single-ai-settings { display: grid; gap: 14px; min-width: 0; }
.single-ai-section { background: #fff; border: 1px solid #e9ebef; border-radius: 12px; padding: 16px; min-width: 0; }
.single-ai-section-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
.single-ai-section-head strong { font-size: 14px; }
.single-ai-section-head .ant-typography { font-size: 12px; }
.single-ai-reference-grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: repeat(2, 1fr); aspect-ratio: 3 / 2; gap: 8px; min-height: 220px; }
.single-ai-reference-grid.outpaint { grid-template-columns: 1fr; }
.single-ai-main-image, .single-ai-aux-image { position: relative; min-width: 0; min-height: 0; overflow: hidden; border-radius: 9px; background: #f3f4f6; }
.single-ai-main-image { grid-row: span 2; }
.single-ai-main-image img, .single-ai-aux-image img { width: 100%; height: 100%; display: block; object-fit: contain; }
.single-ai-main-image > span, .single-ai-aux-image > span { position: absolute; left: 8px; bottom: 8px; border-radius: 5px; padding: 3px 7px; background: rgba(20, 24, 33, .7); color: #fff; font-size: 11px; }
.single-ai-aux-image > .ant-btn { position: absolute; top: 6px; right: 6px; }
.single-ai-aux-image > button:not(.ant-btn) { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #c9ccd3; background: #fafafa; cursor: pointer; color: #737984; }
.single-ai-aux-image > button:not(.ant-btn) span { position: static; background: none; color: inherit; }
.single-ai-aux-image > .single-ai-change-ref { position: absolute !important; right: 6px; bottom: 6px; width: auto !important; height: auto !important; padding: 3px 7px; border: 0 !important; border-radius: 5px; background: rgba(20, 24, 33, .7) !important; color: white !important; font-size: 11px; }
.single-ai-result-frame { width: min(100%, 300px); max-height: 440px; margin: 0 auto 10px; overflow: hidden; background: #f7f8fa; border: 1px dashed #d9dde4; border-radius: 9px; display: flex; justify-content: center; align-items: center; }
.single-ai-result-frame .ant-image { display: block; width: 100%; height: 100%; }
.single-ai-result-frame .ant-image img { display: block; width: 100%; height: 100%; object-fit: contain; }
.single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px; text-align: center; color: #808793; font-size: 12px; }
.single-ai-empty strong { color: #30343b; font-size: 14px; }
.single-ai-sparkle { color: #25ab65; font-size: 32px; }
.single-ai-settings .ant-typography-secondary { display: block; margin-top: 6px; font-size: 12px; }
.single-ai-style-grid { display: flex; flex-wrap: wrap; gap: 7px; }
.single-ai-style-grid button { border: 1px solid #dce0e6; background: #fff; border-radius: 18px; padding: 5px 12px; cursor: pointer; }
.single-ai-style-grid button.selected { border-color: #20a260; color: #16874f; background: #effaf3; }
.single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 74px; gap: 7px; margin-bottom: 8px; }
.single-ai-dimensions .ant-input-number { width: 100%; }
.single-ai-advanced { display: grid; gap: 8px; }
.single-ai-picker { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-height: 420px; overflow: auto; }
.single-ai-picker button { min-width: 0; padding: 4px; border: 1px solid #ddd; background: #fff; border-radius: 8px; cursor: pointer; }
.single-ai-picker img { display: block; width: 100%; aspect-ratio: 1; object-fit: contain; }
.single-ai-picker span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.single-ai-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.single-ai-footer > div { display: flex; gap: 8px; }
@media (max-width: 760px) { .single-ai-layout { grid-template-columns: 1fr; } .single-ai-footer > .ant-typography { display: none; } .single-ai-footer { justify-content: flex-end; } }
+156 -303
View File
@@ -1,46 +1,61 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Checkbox, Drawer, Image, Input, InputNumber, message, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
import {
DEFAULT_IMAGE_MODEL, STYLE_SET_OPTIONS, cancelSuite, getSuite,
imageEditSingle, ProductDimensions, WatermarkPayload,
} from '@/services/suite';
import './AiImageGenModal.css';
const { Text } = Typography;
type ImageOption = { url: string; name: string };
type Ratio = '3:4' | '1:1' | '4:5';
type Language = 'ru' | 'ar' | 'en';
type Background = 'auto' | 'white' | 'solid' | 'scene' | 'reference';
interface Props {
open: boolean;
productId: string;
/** 待生成的源图(采集图或生成图) */
source: { url: string; name: string; afterAssetId?: string } | null;
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
referenceOptions: Array<{ url: string; name: string }>;
source: (ImageOption & { afterAssetId?: string }) | null;
referenceOptions: ImageOption[];
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
onSubmitted?: (suiteId: string) => void;
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [auxiliary, setAuxiliary] = useState<(ImageOption | null)[]>([null, null]);
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
const [generateSizeChart, setGenerateSizeChart] = useState(false);
const [ratio, setRatio] = useState<Ratio>('3:4');
const [translateText, setTranslateText] = useState(false);
const [language, setLanguage] = useState<Language>('ru');
const [background, setBackground] = useState<Background>('auto');
const [backgroundDescription, setBackgroundDescription] = useState('');
const [styleSet, setStyleSet] = useState<number>(2);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [showStylePrompt, setShowStylePrompt] = useState(false);
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState('');
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set());
const [advancedOpen, setAdvancedOpen] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const activeTaskRef = useRef<string | null>(null);
const previousSourceRef = useRef<string | null>(null);
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
const stylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((item) => item.value === styleSet)?.prompt ?? '';
const stopPolling = () => {
pollRunRef.current += 1;
@@ -50,21 +65,25 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
pollAbortRef.current = null;
};
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
useEffect(() => {
if (open && !activeTaskRef.current) {
setPrompt('');
setMode('edit');
setDimensions({ unit: 'mm' });
setGenerateSizeChart(false);
setResultUrl('');
setGenerating(false);
setTaskId('');
setTaskError('');
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set());
}
if (!open || !source || activeTaskRef.current || previousSourceRef.current === source.url) return;
previousSourceRef.current = source.url;
setAuxiliary([null, null]);
setPrompt('');
setMode('edit');
setRatio('3:4');
setTranslateText(false);
setLanguage('ru');
setBackground('auto');
setBackgroundDescription('');
setDimensions({ unit: 'cm' });
setSizeChartOverride(null);
setResultUrl('');
setResultRatio('3:4');
setTaskError('');
setTaskId('');
}, [open, source?.url]);
useEffect(() => { if (!open) previousSourceRef.current = null; }, [open]);
useEffect(() => () => stopPolling(), []);
const pollTask = (suiteId: string) => {
@@ -73,12 +92,8 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
const controller = new AbortController();
pollAbortRef.current = controller;
activeTaskRef.current = suiteId;
const startedAt = Date.now();
let failures = 0;
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const schedule = (delay: number) => { pollTimerRef.current = setTimeout(() => void poll(), delay); };
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
@@ -100,34 +115,24 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
if (result) {
setResultUrl(result.url);
setTaskError(suite.error || '');
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(result.url);
} else if (suite.status === 'cancelled') {
setTaskError('任务已取消');
message.info('任务已取消');
message.success('单张图片生成完成');
} else {
const detail = suite.error || '生成失败,未返回图片';
const detail = suite.status === 'cancelled' ? '任务已取消' : suite.error || '生成失败,未返回图片';
setTaskError(detail);
message.error(detail);
if (suite.status !== 'cancelled') message.error(detail);
}
return;
}
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
if (Date.now() - startedAt > 6 * 60_000) {
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
}
schedule(3000);
} catch (e) {
} catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
failures += 1;
if (failures >= 3) {
finish();
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。';
setTaskError(detail);
message.error(detail);
setTaskError('查询进度失败,任务仍可能在后台运行;可在生成结果区域查看。');
return;
}
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
schedule(Math.min(3000 * (2 ** failures), 12_000));
}
};
@@ -135,50 +140,37 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
};
const onGenerate = async () => {
if (!source) return;
if (selectedReferenceUrls.size === 0) {
message.warning('请至少选择一张参考底图');
return;
}
if (mode === 'outpaint' && selectedReferenceUrls.size !== 1) {
message.warning('仅扩图只能选择一张参考底图');
return;
}
const hasAnyDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
if (generateSizeChart && !hasAnyDimensions) {
message.warning('生成尺寸标注图前,请至少填写一项商品尺寸');
if (!source?.url || generating) return;
if (mode === 'edit' && background === 'reference' && !auxiliary.some(Boolean)) {
message.warning('请选择一张辅助参考图作为背景参考');
return;
}
setGenerating(true);
setResultUrl('');
setResultRatio(mode === 'outpaint' ? '3:4' : ratio);
setTaskError('');
try {
const r = await imageEditSingle({
const response = await imageEditSingle({
product_id: productId,
image_urls: Array.from(selectedReferenceUrls),
prompt: prompt.trim(),
mode,
model,
append: true,
after_asset_id: source.afterAssetId,
main_image_url: source.url,
reference_image_urls: mode === 'outpaint' ? [] : auxiliary.flatMap((item) => item ? [item.url] : []),
prompt: prompt.trim(), mode, model: DEFAULT_IMAGE_MODEL,
ratio: mode === 'outpaint' ? '3:4' : ratio, translate_text: mode === 'edit' && translateText,
target_language: language, style_set: styleSet, style_prompt: stylePrompt,
background, background_description: backgroundDescription.trim() || null,
generate_size_chart: mode === 'edit' && generateSizeChart,
dimensions: hasDimensions ? dimensions : null,
append: true, after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined,
generate_size_chart: generateSizeChart,
dimensions: hasAnyDimensions ? dimensions : null,
});
setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
if (onSubmitted) {
onSubmitted(r.suite_id);
onClose();
} else {
// 独立使用该组件时仍保留自身轮询能力。
pollTask(r.suite_id);
}
} catch (e) {
message.error(apiErrorMessage(e));
setTaskError(apiErrorMessage(e));
setTaskId(response.suite_id);
onSubmitted?.(response.suite_id);
pollTask(response.suite_id);
} catch (error) {
const detail = apiErrorMessage(error);
setTaskError(detail);
setGenerating(false);
message.error(detail);
}
};
@@ -190,228 +182,89 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
setTaskError('任务已取消;已完成的图片会保留在生成结果中。');
} catch (e) {
message.error(apiErrorMessage(e));
}
setTaskError('任务已取消');
} catch (error) { message.error(apiErrorMessage(error)); }
};
const referenceImages = Array.from(
new Map(
[...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])]
.filter((image) => image.url)
.map((image) => [image.url, image]),
).values(),
);
const toggleReference = (url: string) => {
if (generating) return;
setSelectedReferenceUrls((previous) => {
if (mode === 'outpaint') return new Set([url]);
const next = new Set(previous);
if (next.has(url)) next.delete(url); else next.add(url);
return next;
});
const choices = Array.from(new Map(referenceOptions.filter((item) => item.url && item.url !== source?.url)
.map((item) => [item.url, item])).values());
const chooseReference = (item: ImageOption) => {
if (choosingSlot === null) return;
setAuxiliary((previous) => previous.map((selected, index) =>
index === choosingSlot ? item : selected?.url === item.url ? null : selected));
setChoosingSlot(null);
};
const changeMode = (nextMode: 'edit' | 'outpaint') => {
setMode(nextMode);
if (nextMode === 'outpaint') {
setGenerateSizeChart(false);
setModel('gpt-image-2@pcore');
setSelectedReferenceUrls((previous) => {
const first = previous.values().next().value as string | undefined;
return new Set(first ? [first] : (source?.url ? [source.url] : []));
});
}
};
return (
<>
{open && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
background: '#fff',
borderRadius: 10,
padding: 20,
width: 760,
maxWidth: '92vw',
maxHeight: '88vh',
overflow: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Radio.Group
size="small"
value={mode}
buttonStyle="solid"
disabled={generating}
onChange={(e) => changeMode(e.target.value)}
>
<Radio.Button value="edit">AI 编辑</Radio.Button>
<Radio.Button value="outpaint">仅扩图至 3:4</Radio.Button>
</Radio.Group>
</div>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
return <>
<Drawer className="single-ai-drawer" title={<div className="single-ai-title">单张 AI 编辑 <Text type="secondary">每次生成 1 张</Text></div>}
placement="right" width="min(1040px, 100vw)" open={open} onClose={onClose} destroyOnHidden={false}
footer={<div className="single-ai-footer"><Text type="secondary">生成结果会同时保存到「生成结果」</Text><div>
{generating && taskId && <Button danger onClick={() => void onCancelTask()}>取消任务</Button>}
<Button onClick={onClose}>关闭</Button>
<Button type="primary" loading={generating} disabled={!source} onClick={() => void onGenerate()}>{resultUrl ? '重新生成 1 张' : '生成 1 张'}</Button>
</div></div>}>
<div className="single-ai-layout">
<div className="single-ai-media">
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>主图与辅助参考</strong><Text type="secondary">{mode === 'outpaint' ? '仅使用主图' : '主图固定 · 辅图最多 2 张'}</Text></div>
<div className={`single-ai-reference-grid${mode === 'outpaint' ? ' outpaint' : ''}`}>
<div className="single-ai-main-image"><img src={source?.url} alt={source?.name || '主图'} /><span>主图 · 编辑底图</span></div>
{mode === 'edit' && auxiliary.map((item, index) => <div className="single-ai-aux-image" key={index}>
{item ? <><img src={item.url} alt={item.name} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setAuxiliary((previous) => previous.map((value, i) => i === index ? null : value))} /></>
: <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /><span>添加辅图 {index + 1}</span></button>}
{item && <button className="single-ai-change-ref" type="button" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button>}
</div>)}
</div>
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 7 }}>
<Text strong style={{ fontSize: 13 }}>参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>已选 {selectedReferenceUrls.size} 张</Text>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 8, maxHeight: 190, overflowY: 'auto', padding: 2 }}>
{referenceImages.map((image) => {
const selected = selectedReferenceUrls.has(image.url);
return (
<button
key={image.url}
type="button"
title={`${selected ? '取消选择' : '选择'}:${image.name}`}
onClick={() => toggleReference(image.url)}
disabled={generating}
style={{ position: 'relative', padding: 2, border: `2px solid ${selected ? '#1677ff' : '#e5e7eb'}`, borderRadius: 7, background: selected ? '#e6f4ff' : '#fff', cursor: generating ? 'not-allowed' : 'pointer', aspectRatio: '1 / 1', overflow: 'hidden' }}
>
<img src={image.url} alt={image.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', borderRadius: 4 }} />
{selected && <span style={{ position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: '50%', background: '#1677ff', color: '#fff', fontSize: 12, lineHeight: '18px' }}>✓</span>}
</button>
);
})}
</div>
</section>
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>AI 生成结果</strong><Text type="secondary">按 {resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio} 展示</Text></div>
<div className="single-ai-result-frame" style={{ aspectRatio: (resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio).replace(':', ' / ') }}>
{resultUrl ? <Image src={resultUrl} alt="AI 生成结果" /> : generating ? <div className="single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会直接展示在这里</span></div>
: <div className="single-ai-empty"><span className="single-ai-sparkle">✦</span><strong>生成的图片会展示在这里</strong><span>主图是编辑底图,生成结果单独显示</span></div>}
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Image
src={source?.url}
alt="当前图片"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{mode === 'edit' && <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: generateSizeChart ? 12 : 0 }}>
<Checkbox
checked={generateSizeChart}
onChange={(e) => setGenerateSizeChart(e.target.checked)}
disabled={generating}
>
生成尺寸标注图(未知方向留空)
</Checkbox>
</div>}
{mode === 'edit' && generateSizeChart && (
<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
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={mode === 'outpaint'
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
disabled={generating}
/>
{mode === 'outpaint' && (
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。
</Text>
)}
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 0 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS
.filter((m) => mode !== 'outpaint' || m.value === 'gpt-image-2@pcore')
.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
<Button type="primary" loading={generating} onClick={onGenerate}>
提交任务
</Button>
</div>
</div>
</div>
<Alert
style={{ marginTop: 16 }}
type={taskError && !generating ? 'error' : 'info'}
showIcon
message={
generating
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
}
/>
{generating && taskId && (
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
取消此任务
</Button>
)}
</div>
</div>
{resultUrl && (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong>生成结果</Text>
<a href={resultUrl} target="_blank" rel="noreferrer">
打开原图
</a>
</Space>
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
</div>
)}
</div>
{resultUrl && <a href={resultUrl} target="_blank" rel="noreferrer">打开原图</a>}
{taskError && <Alert style={{ marginTop: 10 }} type="error" showIcon message={taskError} />}
</section>
</div>
)}
</>
);
<div className="single-ai-settings">
<section className="single-ai-section"><div className="single-ai-section-head"><strong>编辑要求</strong></div>
<Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder={mode === 'outpaint' ? '描述新增边缘的画面;原图区域会保持不变' : '描述要改什么,例如:保留商品外观,换成温暖的客厅背景,突出材质质感'} />
</section>
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
{translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} />
<Text type="secondary">生成时识别并翻译图片文案,重新排版;不会额外发起识图请求。</Text></>}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>背景</strong></div>
<Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[
{ value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' },
{ value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' },
]} />
{['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>视觉风格</strong></div>
<div className="single-ai-style-grid">{STYLE_SET_OPTIONS.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
<Button type="link" size="small" style={{ paddingLeft: 0, marginTop: 6 }} onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
{showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((previous) => ({ ...previous, [styleSet]: event.target.value }))} />}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
<div className="single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((previous) => ({ ...previous, [key]: typeof value === 'number' ? value : undefined }))} />)}
<Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((previous) => ({ ...previous, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
{hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
<Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向。</Text>
</section>}
<section className="single-ai-section"><div className="single-ai-section-head"><strong>画面比例</strong></div>
<Radio.Group optionType="button" buttonStyle="solid" value={mode === 'outpaint' ? '3:4' : ratio} disabled={generating || mode === 'outpaint'} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
<Button type="link" size="small" style={{ display: 'block', paddingLeft: 0, marginTop: 8 }} onClick={() => setAdvancedOpen(!advancedOpen)}>{advancedOpen ? '收起生成方式' : '生成方式'}</Button>
{advancedOpen && <div className="single-ai-advanced">
<Select value={mode} disabled={generating} onChange={(value: 'edit' | 'outpaint') => setMode(value)} options={[{ value: 'edit', label: 'AI 编辑' }, { value: 'outpaint', label: '仅扩图至 3:4(保留原图)' }]} />
<Text type="secondary">使用 Pcore 中转 · gpt-image-2{mode === 'outpaint' ? ';原图区域会覆盖回生成结果。' : ''}</Text>
</div>}
</section>
</div>
</div>
</Drawer>
<Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} footer={null} onCancel={() => setChoosingSlot(null)}>
<div className="single-ai-picker">{choices.length ? choices.map((item) => <button type="button" key={item.url} onClick={() => chooseReference(item)}><img src={item.url} alt={item.name} /><span>{item.name}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
</Modal>
</>;
}
+2 -2
View File
@@ -161,7 +161,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState('');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
@@ -1230,7 +1230,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'}
value={dimensions.unit ?? 'cm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
/>
+11 -1
View File
@@ -200,7 +200,10 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload {
product_id?: string;
/** 兼容旧接口的单张底图;新界面使用 image_urls。 */
/** 明确的生成底图;辅助图始终在其后。 */
main_image_url?: string;
reference_image_urls?: string[];
/** 兼容旧接口的单张底图;新界面使用 main_image_url。 */
image_url?: string;
/** 选中的参考底图,可多张。 */
image_urls?: string[];
@@ -208,6 +211,13 @@ export interface ImageEditSinglePayload {
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
mode?: 'edit' | 'outpaint';
model: string;
ratio?: '3:4' | '1:1' | '4:5';
target_language?: 'ru' | 'ar' | 'en';
translate_text?: boolean;
style_set?: number;
style_prompt?: string | null;
background?: 'auto' | 'white' | 'solid' | 'scene' | 'reference';
background_description?: string | null;
append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string;