Files
ozon-seller-kit/studio/src/pages/trial/TrialSuitePanel.tsx
T
2026-08-29 17:13:06 +08:00

1110 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 04 图片与 AI 生图(docs/v2.1/image-suite.md):
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
import { ProductAsset, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import {
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
deleteAsset, downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
type PlanItem,
} from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal';
const { Text } = Typography;
const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark';
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const [text, setText] = useState(String(value));
useEffect(() => setText(String(value)), [value]);
const clamp = (v: number) => Math.max(0, Math.min(5, Number.isNaN(v) ? 0 : v));
const commit = () => onChange(clamp(parseInt(text, 10)));
const btnBase: CSSProperties = {
width: 26,
height: 26,
padding: 0,
border: 'none',
background: '#f5f5f5',
cursor: 'pointer',
fontSize: 15,
lineHeight: '26px',
color: '#555',
};
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
borderRadius: 8,
overflow: 'hidden',
background: '#fff',
}}
>
<button
type="button"
title="减少"
style={{ ...btnBase, color: value <= 0 ? '#ccc' : '#555', cursor: value <= 0 ? 'not-allowed' : 'pointer' }}
disabled={value <= 0}
onClick={() => onChange(clamp(value - 1))}
>
−
</button>
<input
value={text}
onChange={(e) => setText(e.target.value.replace(/[^\d]/g, ''))}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
title="可直接输入数量(0-5)"
style={{
width: 34,
height: 26,
textAlign: 'center',
border: 'none',
outline: 'none',
fontSize: 13,
fontWeight: 600,
color: value === 0 ? '#bbb' : '#333',
background: '#fff',
}}
/>
<button
type="button"
title="增加"
style={{ ...btnBase, color: value >= 5 ? '#ccc' : '#555', cursor: value >= 5 ? 'not-allowed' : 'pointer' }}
disabled={value >= 5}
onClick={() => onChange(clamp(value + 1))}
>
+
</button>
</span>
);
}
interface Props {
product: ProductDetail;
assets: ProductAsset[];
onRefreshAssets: () => void;
}
/** 商品信息 → 规划/生成的文本素材(包装字段覆盖回参数表,对齐 image-suite-studio editedTexts) */
function buildSuiteTexts(product: ProductDetail): SuiteTextPayload[] {
const raw = (product.raw ?? {}) as Record<string, unknown>;
const texts: SuiteTextPayload[] = [];
const title = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
if (title) texts.push({ kind: 'title', content: title });
if (raw.price) texts.push({ kind: 'price', content: String(raw.price) });
(['brand', 'sales', 'shop'] as const).forEach((k) => {
const v = raw[k];
if (v) texts.push({ kind: k, content: String(v) });
});
const sellingPoint = (raw.selling_point as string) ?? (raw.sellingPoints as string);
if (sellingPoint) texts.push({ kind: 'selling_point', content: sellingPoint });
let pairs = Array.isArray(raw.params) ? [...(raw.params as Array<{ key: string; value: string }>)] : [];
pairs = pairs.filter((p) => !/尺寸|长宽高|重量/i.test(p.key));
if (product.weight != null && product.weight > 0) pairs.push({ key: '重量', value: `${product.weight}g` });
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
if (product.depth != null && product.width != null && product.height != null) {
const d = { l: product.depth * scale, w: product.width * scale, h: product.height * scale };
pairs.push({ key: '产品尺寸', value: `${d.l}×${d.w}×${d.h}` });
}
if (pairs.length) texts.push({ kind: 'params', content: '', pairs });
if (raw.desc) texts.push({ kind: 'desc', content: String(raw.desc) });
return texts;
}
function loadWatermark(): WatermarkPayload {
try {
const s = localStorage.getItem(WATERMARK_STORAGE_KEY);
if (s) return { ...DEFAULT_WATERMARK, ...(JSON.parse(s) as WatermarkPayload) };
} catch { /* 忽略坏数据 */ }
return DEFAULT_WATERMARK;
}
export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Props) {
// message / modal 用 App 上下文版本:静态方法吃不到 ConfigProvider 动态主题(控制台告警)
const { modal, message } = AntdApp.useApp();
// 素材选择(默认全部不勾选,用户按需勾选参考底图;SKU 规格名供 AI 规划 variant 绑定,与勾选无关)
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(() => new Set());
/** 上传成功待自动勾选的 asset id */
const pendingSelectRef = useRef<Set<string>>(new Set());
const [uploadingCount, setUploadingCount] = useState(0);
// 出图方案
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map((p) => ({ ...p })));
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
const [planSummary, setPlanSummary] = useState('');
const [planning, setPlanning] = useState(false);
const [planThenGenerate, setPlanThenGenerate] = useState(false);
const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const [exportingZip, setExportingZip] = useState(false);
const [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重置后丢弃迟到的过期响应 */
const planSeqRef = useRef(0);
// 单张 AI 生图弹窗(afterAssetId:结果插到该素材之后,方便对比)
const [genModal, setGenModal] = useState<{ url: string; name: string; afterAssetId?: string } | null>(null);
// 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]);
const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]);
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
// 图名:取 URL 路径末段(采集图为源站文件名;上传图为落盘文件名),无路径时回退规格名
const assetName = (a: ProductAsset): string => {
const raw = a.source_url || a.stored_url || '';
try {
const seg = decodeURIComponent(new URL(raw, window.location.origin).pathname.split('/').pop() || '');
return seg || a.variant_name || '未命名';
} catch {
return a.variant_name || '未命名';
}
};
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
// 上传完成的素材自动勾选(素材列表刷新后合并)
useEffect(() => {
if (pendingSelectRef.current.size === 0) return;
const next = new Set(selectedKeys);
let touched = false;
pendingSelectRef.current.forEach((id) => {
if (assets.some((a) => a.id === id) && !next.has(id)) {
next.add(id);
touched = true;
}
});
pendingSelectRef.current.clear();
if (touched) setSelectedKeys(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets]);
// 组件卸载停止轮询
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
}, []);
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
const toggleKey = (key: string) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleGroup = (g: string, on: boolean) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
groupImages(g).forEach((a) => (on ? next.add(a.id) : next.delete(a.id)));
return next;
});
};
const uploadButton = (
<Upload
accept="image/*"
multiple
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }) => {
setUploadingCount((c) => c + 1);
try {
const r = await uploadProductAsset(product.id, file as File);
pendingSelectRef.current.add(r.asset_id);
onSuccess?.(r);
} catch (e) {
message.error(`${(file as File).name}:${apiErrorMessage(e)}`);
onError?.(e as Error);
} finally {
setUploadingCount((c) => c - 1);
onRefreshAssets();
}
}}
>
<Button icon={<UploadOutlined />} size="small" loading={uploadingCount > 0}>
上传图片
</Button>
</Upload>
);
// ── 下载采集图片(勾选图打包 ZIP) ─────────────────────────────
const handleExportImages = async () => {
const selected = imgs.filter((a) => selectedKeys.has(a.id));
if (selected.length === 0) {
message.warning('请先勾选要下载的图片');
return;
}
setExportingImages(true);
try {
const blob = await exportImages({
title: productTitle,
images: selected.map((a) => ({
url: assetUrl(a),
groupName: GROUP_LABELS[a.group_key] ?? a.group_key,
variantName: a.variant_name,
key: a.id,
})),
});
downloadBlob(blob, `${cleanFilename(productTitle) || '采集图片'}.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingImages(false);
}
};
// ── AI 智能规划 ─────────────────────────────────────────────
const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? '';
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
const pollSuite = (suiteId: string) => {
stopPolling();
const startedAt = Date.now();
let failCount = 0;
let timeoutWarned = false;
pollRef.current = setInterval(async () => {
try {
const s = await getSuite(suiteId);
setSuite(s);
failCount = 0;
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) {
timeoutWarned = true;
message.warning('任务耗时异常,可能已中断(如服务端重启),可稍后观察或重新生成');
}
if (['done', 'partial', 'failed'].includes(s.status)) {
stopPolling();
setGenerating(false);
if (s.status === 'partial') message.warning(s.error || '部分生成失败,可重试或更换风格重新生成');
if (s.status === 'failed') message.error(s.error || '生成失败');
// Phase B 起服务端会把生成图回写素材(generated 组)
if (s.status !== 'failed') onRefreshAssets();
}
} catch {
failCount += 1;
if (failCount >= 3) {
stopPolling();
setGenerating(false);
message.error('连续查询生成进度失败,已停止跟踪;若服务端刚重启,请重新生成');
}
}
}, 3000);
};
const startGenerate = async (planOverride?: PlanItem[]): Promise<void> => {
if (selectedKeys.size === 0) {
message.warning('请先在采集图片区勾选参考底图');
return;
}
const activePlan = (planOverride ?? plan).filter((p) => p.count > 0);
if (activePlan.length === 0) {
message.warning('出图方案的张数都是 0');
return;
}
setGenerating(true);
setSuite(null);
try {
const { suite_id } = await generateSuite({
product_id: product.id,
texts: buildSuiteTexts(product),
images: imgs
.filter((a) => selectedKeys.has(a.id))
.map((a) => ({ url: assetUrl(a), group_key: a.group_key, variant_name: a.variant_name })),
style_set: styleSet,
style_prompt: currentStylePrompt || null,
requirements: requirements.trim() || null,
plan: activePlan,
platform: 'ozon',
model,
// 水印开启才下发,关闭时不带(服务端按无水印处理)
watermark: watermark.enabled ? watermark : undefined,
});
pollSuite(suite_id);
} catch (e) {
setGenerating(false);
message.error(apiErrorMessage(e));
}
};
const handlePlan = async () => {
const seq = ++planSeqRef.current;
setPlanning(true);
try {
const skuVariants = Array.from(
new Set(assets.filter((a) => a.group_key === 'sku' && a.variant_name).map((a) => a.variant_name!)),
);
const stats: Record<string, number> = {};
imgs.forEach((a) => {
stats[a.group_key] = (stats[a.group_key] ?? 0) + 1;
});
const data = await planSuite({
product_id: product.id,
texts: buildSuiteTexts(product),
sku_variants: skuVariants,
image_stats: stats,
platform: 'ozon',
requirements: requirements.trim() || null,
});
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
setPlan(data.items);
setPlanSource('ai');
setPlanSummary(data.summary);
if (planThenGenerate) {
await startGenerate(data.items);
} else {
const total = data.items.reduce((s, i) => s + i.count, 0);
message.info(`AI 方案已生成:${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`);
}
} catch (e) {
if (seq !== planSeqRef.current) return;
message.error(apiErrorMessage(e));
} finally {
if (seq === planSeqRef.current) setPlanning(false);
}
};
const handleGenerate = () => {
if (selectedKeys.size === 0) {
message.warning('请先在采集图片区勾选参考底图');
return;
}
if (totalPlanned === 0) {
message.warning('出图方案的张数都是 0');
return;
}
modal.confirm({
title: '生成电商套图',
content: (
<div>
<div>
目标平台「Ozon」(俄文文案 · 3:4 图片),模型「{model}」,风格「
{STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考底图{' '}
{selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。
</div>
</div>
),
okText: '开始生成',
cancelText: '取消',
onOk: () => startGenerate(),
});
};
const handleExportZip = async () => {
if (!suite) return;
setExportingZip(true);
try {
const blob = await downloadSuiteZip(suite.id);
downloadBlob(blob, `${cleanFilename(productTitle) || '套图'}.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingZip(false);
}
};
// ── 导出全部生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
const handleExportResult = async () => {
const suiteOk = (suite?.images ?? []).filter((i) => i.status === 'ok');
if (suiteOk.length === 0 && generatedAssets.length === 0) {
message.warning('暂无生图结果可导出');
return;
}
setExportingResult(true);
try {
const images = [
...suiteOk.map((img, i) => ({
url: img.url,
groupName: '套图结果',
variantName: null,
key: `suite-${String(i + 1).padStart(3, '0')}`,
})),
...generatedAssets.map((a, i) => ({
url: assetUrl(a),
groupName: '单张生图',
variantName: a.variant_name,
key: `single-${String(i + 1).padStart(3, '0')}`,
})),
];
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingResult(false);
}
};
const setPlanCount = (idx: number, count: number) => {
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p)));
};
const togglePlanRow = (idx: number) => {
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p)));
};
const planAllEnabled = plan.every((p) => p.count >= 1);
const togglePlanAll = (on: boolean) => {
setPlan((prev) => prev.map((p) => (on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 })));
};
const doneCount = suite?.images.filter((i) => i.status === 'ok').length ?? 0;
const suiteTotal = suite?.total ?? totalPlanned;
const patchWatermark = (patch: Partial<WatermarkPayload>) => {
setWatermark((prev) => {
const next = { ...prev, ...patch };
try {
localStorage.setItem(WATERMARK_STORAGE_KEY, JSON.stringify(next));
} catch { /* 忽略存储失败 */ }
return next;
});
};
const watermarkPopup = (
<div style={{ width: 280 }}>
<Checkbox
checked={watermark.enabled}
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
>
生成图加水印
</Checkbox>
{watermark.enabled && (
<>
<div style={{ margin: '10px 0 6px' }}>
<Radio.Group
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(e) => patchWatermark({ type: e.target.value })}
options={[
{ value: 'image', label: '图片徽章' },
{ value: 'text', label: '文字' },
]}
/>
</div>
{watermark.type === 'text' && (
<Input
size="small"
value={watermark.text}
placeholder="水印文字"
onChange={(e) => patchWatermark({ text: e.target.value })}
/>
)}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
不透明度(%)
</Text>
<InputNumber
size="small"
style={{ width: '100%' }}
min={1}
max={100}
step={5}
value={watermark.opacity}
onChange={(v) => patchWatermark({ opacity: v ?? 30 })}
/>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
位置固定右下角;重新生成后生效,已生成的图不变
</Text>
</>
)}
</div>
);
const selectableCount = imgs.length;
/** 删除一张已生成的图片(二次确认后调用),成功后刷新素材列表 */
const handleDeleteGenerated = async (assetId: string) => {
try {
await deleteAsset(assetId);
message.success('已删除');
onRefreshAssets();
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── 4 采集图片 ── */}
<div id="section-collect">
<Card
title={`4 采集图片(已选 ${selectedKeys.size} / ${selectableCount})`}
extra={
<Space>
{uploadButton}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingImages}
onClick={handleExportImages}
>
下载采集图片
</Button>
</Space>
}
>
{displayGroups.length === 0 ? (
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
) : (
<Image.PreviewGroup>
{displayGroups.map((g) => (
<div key={g} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Text strong>
{GROUP_LABELS[g] ?? g}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>
{groupImages(g).length}
</Text>
</Text>
<a
style={{ fontSize: 12 }}
onClick={() => {
const all = groupImages(g).every((a) => selectedKeys.has(a.id));
toggleGroup(g, !all);
}}
>
{groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'}
</a>
{g === 'main' && (
<Text type="secondary" style={{ fontSize: 11 }}>
勾选的图片作为生图参考底图
</Text>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, minmax(0, 1fr))', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id}>
<div
style={{
position: 'relative',
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: on ? 'rgba(22,119,255,0.06)' : undefined,
}}
>
<Image
src={assetUrl(a)}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: on ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: on ? '#fff' : '#bbb',
border: '1px solid #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 2,
userSelect: 'none',
}}
>
✓
</span>
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
{/* AI 生图:半透明悬浮在图片右下角 */}
<Button
size="small"
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
style={{
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
background: 'rgba(0, 0, 0, 0.45)',
color: '#fff',
borderColor: 'transparent',
}}
>
AI 生图
</Button>
</div>
<div
title={a.variant_name || assetName(a)}
style={{
fontSize: 11,
color: '#999',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{a.variant_name || assetName(a)}
</div>
</div>
);
})}
</div>
</div>
))}
</Image.PreviewGroup>
)}
</Card>
</div>
{/* ── 5 出图方案(左:规划操作+方案列表;右:风格/要求/模型/生成) ── */}
<div id="section-plan">
<Card
title={`5 出图方案(共 ${totalPlanned} 张)`}
extra={planSource === 'ai' && <Tag color="purple">AI 方案</Tag>}
>
<Row gutter={24}>
<Col span={12}>
{/* 左列整块:与价格试算右列相同的浅灰卡片 */}
<Card size="small" style={{ background: '#fafafa' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Checkbox
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
title="勾选后,AI 规划完成将自动开始生成"
>
规划并生成
</Checkbox>
<Button
type="primary"
ghost
icon={<ThunderboltOutlined />}
loading={planning}
onClick={handlePlan}
>
AI 智能规划
</Button>
</div>
<div>
{plan.map((p, idx) => (
<div
key={`${p.kind}-${idx}`}
onClick={() => togglePlanRow(idx)}
title="点击勾选/取消该方案(数量用右侧加减调整)"
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '6px 8px',
borderRadius: 6,
cursor: 'pointer',
opacity: p.count === 0 ? 0.45 : 1,
background: p.count === 0 ? 'transparent' : 'rgba(22,119,255,0.03)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Space size={6} wrap>
<Text strong={p.count > 0}>{p.title}</Text>
{p.variant_name && <Tag color="purple">{p.variant_name}</Tag>}
{p.detail && (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: p.detail }}>
{p.detail}
</Text>
)}
{p.prompt_hint && (
<Text type="secondary" style={{ fontSize: 12 }}>🎯 {p.prompt_hint}</Text>
)}
</Space>
</div>
<span onClick={(e) => e.stopPropagation()}>
<PlanStepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
</span>
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0' }}>
<Checkbox
checked={planAllEnabled}
onChange={(e) => togglePlanAll(e.target.checked)}
title="勾选:全部方案至少 1 张(>1 张保留原数量);取消:全部改为 0,方便单独勾选一种方案"
>
全部方案
</Checkbox>
{planSource === 'ai' && (
<a
style={{ fontSize: 12 }}
onClick={() => {
setPlan(DEFAULT_PLAN.map((p) => ({ ...p })));
setPlanSource('default');
setPlanSummary('');
}}
>
恢复默认方案
</a>
)}
<Text type="secondary" style={{ fontSize: 12, flex: 1, whiteSpace: 'normal', wordBreak: 'break-all' }}>
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
</Text>
</div>
</Card>
</Col>
<Col span={12}>
<div>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'rgba(0,0,0,0.82)', marginBottom: 6 }}>
视觉风格
</div>
<Segmented
value={styleSet}
onChange={(v) => setStyleSet(v as number)}
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
风格提示词(可编辑,直接决定生成画面风格)
{stylePrompts[styleSet] !== undefined && (
<a
style={{ marginLeft: 8 }}
onClick={() =>
setStylePrompts((p) => {
const n = { ...p };
delete n[styleSet];
return n;
})
}
>
恢复默认
</a>
)}
</Text>
<Input.TextArea
rows={3}
value={currentStylePrompt}
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
生图要求(优先级最高,强制约束,会覆盖其他设定)
</Text>
<Input.TextArea
rows={3}
value={requirements}
onChange={(e) => setRequirements(e.target.value)}
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
/>
</div>
<Row align="middle" style={{ marginTop: 12, gap: 12 }} justify="end" wrap={false}>
<Select
style={{ width: 260 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS.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>
)}
/>
<Button
type="primary"
disabled={selectedKeys.size === 0 || generating || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</Button>
</Row>
{generating && (
<Progress
style={{ marginTop: 12 }}
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
size={['100%', 10]}
status="active"
format={() => `${doneCount}/${suiteTotal}`}
/>
)}
</div>
</Col>
</Row>
</Card>
</div>
{/* ── 生成结果 ── */}
<div id="section-result">
<Card
title="6 生成结果"
extra={
<Space>
{suite && ['done', 'partial'].includes(suite.status) && (
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
导出 ZIP
</Button>
)}
{/* 水印设置(作用于下次生成) */}
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
水印
</Button>
</Popover>
{/* 导出全部生图结果(套图 + 单张生图) */}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingResult}
disabled={(!suite || !['done', 'partial'].includes(suite.status)) && generatedAssets.length === 0}
onClick={handleExportResult}
>
导出图片
</Button>
</Space>
}
>
{!suite && generatedAssets.length === 0 ? (
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : (
<>
{suite && (
<>
<div style={{ marginBottom: 8 }}>
<Space wrap size={8}>
<Text type="secondary" style={{ fontSize: 12 }}>
状态:
</Text>
<Tag
color={
suite.status === 'done'
? 'green'
: suite.status === 'failed'
? 'red'
: 'processing'
}
>
{suite.status === 'running'
? '生成中'
: suite.status === 'done'
? '完成'
: suite.status === 'partial'
? '部分失败'
: suite.status === 'pending'
? '排队中'
: '失败'}
</Tag>
<Tag>{suite.ratio} 图片</Tag>
<Tag>{suite.lang}文案</Tag>
<Tag>风格「{STYLE_SET_OPTIONS.find((s) => s.value === suite.style_set)?.label ?? '自定义'}」</Tag>
{suite.total != null && <Tag>共 {suite.total} 张</Tag>}
</Space>
</div>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{suite.images
.filter((img) => img.status === 'ok' || img.status === 'failed')
.map((img) => (
<div
key={img.type_id + img.name}
style={{ width: 112, position: 'relative' }}
title={img.error || img.name}
>
{img.status === 'ok' ? (
<Image
src={img.url}
width={100}
height={133}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
) : (
<div
style={{
width: 100,
height: 133,
borderRadius: 6,
background: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ff4d4f',
}}
>
✗
</div>
)}
<div
style={{
fontSize: 11,
color: img.status === 'ok' ? 'rgba(0,0,0,0.45)' : '#ff4d4f',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={img.error || img.name}
>
{img.status === 'failed' && img.error ? img.error : img.name}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
{generatedAssets.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
生图记录
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(一键生成与单张生图的落库历史,刷新后仍保留 · {generatedAssets.length} 张)
</Text>
</Text>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
{generatedAssets.map((a) => (
<div key={a.id} style={{ width: 100, position: 'relative' }}>
<Image
src={assetUrl(a)}
width={100}
height={133}
style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
/>
{/* 删除(二次确认) */}
<Popconfirm
title="删除图片"
description="确认删除这张已生成的图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteGenerated(a.id)}
>
<span
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: 'rgba(255,255,255,0.95)',
color: '#ff4d4f',
border: '1px solid #ffccc7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 1,
userSelect: 'none',
lineHeight: 1,
}}
>
✕
</span>
</Popconfirm>
<div
style={{
fontSize: 11,
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title="基于这张图再次 AI 编辑"
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || 'AI 编辑', afterAssetId: a.id })}
>
<a style={{ color: '#1677ff' }}>AI 编辑</a>
{a.created_at && (
<span style={{ color: 'rgba(0,0,0,0.45)', marginLeft: 4 }}>
{new Date(a.created_at).toLocaleTimeString()}
</span>
)}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
</div>
)}
</>
)}
</Card>
</div>
{/* 单张 AI 生图弹窗 */}
<AiImageGenModal
open={genModal !== null}
productId={product.id}
source={genModal}
onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()}
/>
</div>
);
}