Files
ozon-seller-kit/studio/src/pages/trial/TrialSuitePanel.tsx
T
2026-08-31 14:37:28 +08:00

1335 lines
62 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, type ReactNode } from 'react';
import {
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress,
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,
applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset,
type PlanItem,
} from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal';
import { CollectionImageGroups, FailedSuiteImages, GeneratedImageGrid } from './TrialImageGrids';
import TrialWatermarkSettings from './TrialWatermarkSettings';
import TrialVideoMaker from './TrialVideoMaker';
import { DISPLAY_GROUPS, GROUP_LABELS, type ImagePreviewConfig, type ResultImage } from './trialImageTypes';
const { Text } = Typography;
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 (/[\u0400-\u04FF]/.test(product.name || '')) texts.push({ kind: 'title_ru', content: product.name.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 [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
/** 生图结果选择:套图当次结果与已落库的单张/套图历史共用一套选择状态。 */
const [selectedResultKeys, setSelectedResultKeys] = useState<Set<string>>(() => new Set());
/** 删除后在本次任务的内存结果中同步隐藏,避免刷新前仍可导出已删图片。 */
const [hiddenResultKeys, setHiddenResultKeys] = useState<Set<string>>(() => new Set());
const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
const [applyingWatermark, setApplyingWatermark] = useState(false);
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const watermarkPollAbortRef = useRef<AbortController | null>(null);
const watermarkPollRunRef = useRef(0);
/** 规划请求序号:重置后丢弃迟到的过期响应 */
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;
/** 生成时始终以无水印原图为参考;展示/下载仍使用 current(stored_url)。 */
const assetOriginalUrl = (a: ProductAsset) => a.original_url || assetUrl(a);
// 图名:取 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();
const resultImages = useMemo<ResultImage[]>(() => {
const suiteImages = (suite?.images ?? [])
.filter((img) => img.status === 'done')
.map((img, index) => {
const asset = generatedAssets.find((a) => assetUrl(a) === img.url);
return {
key: `suite-${suite?.id ?? 'current'}-${index}`,
url: img.url,
name: img.name,
groupName: '套图结果',
variantName: null as string | null,
assetId: asset?.id,
source: 'suite' as const,
};
});
const historyImages = generatedAssets.map((asset) => ({
key: `asset-${asset.id}`,
url: assetUrl(asset),
name: assetName(asset),
groupName: '单张生图',
variantName: asset.variant_name,
assetId: asset.id,
source: 'history' as const,
createdAt: asset.created_at,
}));
return [...suiteImages, ...historyImages].filter((img) => !hiddenResultKeys.has(img.key));
}, [generatedAssets, hiddenResultKeys, suite]);
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
const historyResultImages = resultImages.filter((img) => img.source === 'history');
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => img.assetId!)));
const selectedCollectionWatermarkAssetIds = imgs
.filter((asset) => selectedKeys.has(asset.id) && asset.type === 'img' && !!assetUrl(asset))
.map((asset) => asset.id);
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
const selectedWatermarkedResultIds = selectedWatermarkAssetIds.filter((id) => assetById.get(id)?.watermark_enabled);
const selectedWatermarkedCollectionIds = selectedCollectionWatermarkAssetIds.filter((id) => assetById.get(id)?.watermark_enabled);
// 上传完成的素材自动勾选(素材列表刷新后合并)
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]);
const stopPolling = () => {
pollRunRef.current += 1;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current?.abort();
pollAbortRef.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 runId = ++pollRunRef.current;
const controller = new AbortController();
pollAbortRef.current = controller;
const startedAt = Date.now();
let failCount = 0;
let timeoutWarned = false;
try {
sessionStorage.setItem(`trialSuiteTask:${product.id}`, suiteId);
} catch { /* 私密模式等不可写时不影响当前轮询 */ }
const clearSavedTask = () => {
try {
sessionStorage.removeItem(`trialSuiteTask:${product.id}`);
} catch { /* 忽略 sessionStorage 不可用 */ }
};
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current = null;
setGenerating(false);
};
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const poll = async () => {
if (runId !== pollRunRef.current || controller.signal.aborted) return;
try {
const s = await getSuite(suiteId, { signal: controller.signal });
if (runId !== pollRunRef.current) return;
setSuite(s);
failCount = 0;
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
if (!timeoutWarned && ['running', 'queued'].includes(s.status) && Date.now() - startedAt > budgetMs) {
timeoutWarned = true;
message.warning('任务耗时异常,可能已中断(如服务端重启),可稍后观察或重新生成');
}
if (['done', 'partial', 'failed', 'cancelled'].includes(s.status)) {
finish();
clearSavedTask();
if (s.status === 'partial') message.warning(s.error || '部分生成失败,可重试或更换风格重新生成');
if (s.status === 'failed') message.error(s.error || '生成失败');
if (s.status === 'cancelled') message.info('生成任务已取消');
// Phase B 起服务端会把生成图回写素材(generated 组)
if (s.status !== 'failed') onRefreshAssets();
return;
}
// 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。
schedule(3000);
} catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
failCount += 1;
if (failCount >= 3) {
finish();
// 保留 sessionStorage 任务 ID;刷新页面后可再尝试恢复跟踪。
message.error('连续查询生成进度失败,已停止自动跟踪;刷新页面可尝试恢复。若服务端刚重启,请重新生成');
return;
}
// 指数退避:6 秒、12 秒;成功一次后恢复为 3 秒。
schedule(Math.min(3000 * (2 ** failCount), 12_000));
}
};
schedule(0);
};
const stopWatermarkPolling = () => {
watermarkPollRunRef.current += 1;
if (watermarkPollTimerRef.current) clearTimeout(watermarkPollTimerRef.current);
watermarkPollTimerRef.current = null;
watermarkPollAbortRef.current?.abort();
watermarkPollAbortRef.current = null;
};
/** 批量水印与 AI 图片任务共用同一查询接口,但用独立轮询避免相互中断。 */
const pollWatermarkJob = (jobId: string) => {
stopWatermarkPolling();
const runId = ++watermarkPollRunRef.current;
const controller = new AbortController();
watermarkPollAbortRef.current = controller;
let failCount = 0;
try {
sessionStorage.setItem(`trialWatermarkTask:${product.id}`, jobId);
} catch { /* 私密模式等不可写时不影响当前轮询 */ }
const clearSavedTask = () => {
try {
sessionStorage.removeItem(`trialWatermarkTask:${product.id}`);
} catch { /* 忽略 sessionStorage 不可用 */ }
};
const finish = () => {
if (runId !== watermarkPollRunRef.current) return;
if (watermarkPollTimerRef.current) clearTimeout(watermarkPollTimerRef.current);
watermarkPollTimerRef.current = null;
watermarkPollAbortRef.current = null;
setApplyingWatermark(false);
};
const schedule = (delayMs: number) => {
watermarkPollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const poll = async () => {
if (runId !== watermarkPollRunRef.current || controller.signal.aborted) return;
try {
const task = await getSuite(jobId, { signal: controller.signal });
if (runId !== watermarkPollRunRef.current) return;
setWatermarkJob(task);
failCount = 0;
if (['done', 'partial', 'failed', 'cancelled'].includes(task.status)) {
finish();
clearSavedTask();
if (task.status === 'done') message.success(`水印任务已完成:${task.completed ?? task.total ?? 0} 张`);
if (task.status === 'partial') message.warning(`水印任务部分完成:${task.completed ?? 0} 张成功、${task.failed ?? 0} 张失败`);
if (task.status === 'failed') message.error(task.error || '水印任务失败');
if (task.status === 'cancelled') message.info('水印任务已取消');
if (task.status !== 'cancelled') onRefreshAssets();
return;
}
// 上一轮完成后再安排下一轮,避免网络变慢时重叠请求。
schedule(2000);
} catch {
if (controller.signal.aborted || runId !== watermarkPollRunRef.current) return;
failCount += 1;
if (failCount >= 3) {
finish();
message.error('连续查询水印进度失败,刷新页面可继续恢复跟踪');
return;
}
schedule(Math.min(2000 * (2 ** failCount), 12_000));
}
};
schedule(0);
};
// 刷新试算页后自动恢复当前商品仍在运行的套图任务;服务重启导致的任务丢失会按常规错误提示处理。
useEffect(() => {
let savedTaskId = '';
try {
savedTaskId = sessionStorage.getItem(`trialSuiteTask:${product.id}`) || '';
} catch { /* sessionStorage 不可用时跳过恢复 */ }
if (savedTaskId) {
setGenerating(true);
pollSuite(savedTaskId);
} else {
void listProductSuites(product.id, true).then((active) => {
const task = active[0];
if (!task) return;
setSuite(task);
setGenerating(true);
pollSuite(task.id);
}).catch(() => { /* 网络失败时不打断试算页使用 */ });
}
return () => stopPolling();
// 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product.id]);
// 水印任务单独恢复:套图列表刻意排除了 watermark_batch,防止两个区域互相抢进度状态。
useEffect(() => {
setWatermarkJob(null);
let savedTaskId = '';
try {
savedTaskId = sessionStorage.getItem(`trialWatermarkTask:${product.id}`) || '';
} catch { /* sessionStorage 不可用时跳过恢复 */ }
if (savedTaskId) {
setApplyingWatermark(true);
pollWatermarkJob(savedTaskId);
} else {
void listProductWatermarkJobs(product.id, true).then((active) => {
const task = active[0];
if (!task) return;
setWatermarkJob(task);
setApplyingWatermark(true);
pollWatermarkJob(task.id);
}).catch(() => { /* 网络失败时不打断试算页使用 */ });
}
return () => stopWatermarkPolling();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product.id]);
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: assetOriginalUrl(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 handleCancelSuite = async () => {
if (!suite || ['done', 'partial', 'failed', 'cancelled'].includes(suite.status)) return;
try {
const next = await cancelSuite(suite.id);
setSuite(next);
stopPolling();
setGenerating(false);
try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* ignore */ }
message.info('已取消生成任务;已完成的图片会保留在生成记录中');
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const handleRetrySuite = async () => {
if (!suite) return;
try {
const next = await retrySuite(suite.id);
setSuite(next);
setGenerating(true);
pollSuite(next.id);
message.info('失败图片已重新加入队列');
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const handleCancelWatermarkJob = async () => {
if (!watermarkJob || ['done', 'partial', 'failed', 'cancelled'].includes(watermarkJob.status)) return;
try {
const next = await cancelSuite(watermarkJob.id);
setWatermarkJob(next);
stopWatermarkPolling();
setApplyingWatermark(false);
try { sessionStorage.removeItem(`trialWatermarkTask:${product.id}`); } catch { /* ignore */ }
message.info('已取消水印任务;已经完成的图片会保留水印版');
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const handleRetryWatermarkJob = async () => {
if (!watermarkJob) return;
try {
const next = await retrySuite(watermarkJob.id);
setWatermarkJob(next);
setApplyingWatermark(true);
pollWatermarkJob(next.id);
message.info('失败图片已重新加入水印队列');
} catch (e) {
message.error(apiErrorMessage(e));
}
};
const handleRetryWatermarkItem = async (itemId: string) => {
if (!watermarkJob) return;
try {
const next = await retrySuiteItem(watermarkJob.id, itemId);
setWatermarkJob(next);
setApplyingWatermark(true);
pollWatermarkJob(next.id);
message.info('该图片已重新加入水印队列');
} catch (e) {
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(),
});
};
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
const handleExportResult = async () => {
if (resultImages.length === 0) {
message.warning('暂无生图结果可导出');
return;
}
const selected = resultImages.filter((img) => selectedResultKeys.has(img.key));
if (selected.length === 0) {
message.warning('请先勾选要导出的生图结果');
return;
}
setExportingResult(true);
try {
const images = selected.map((img) => ({
url: img.url,
groupName: img.groupName,
variantName: img.variantName,
key: img.key,
}));
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingResult(false);
}
};
const handleDownloadResultImage = async (img: { url: string; name: string; key: string; variantName?: string | null }) => {
setDownloadingResultKeys((prev) => new Set(prev).add(img.key));
try {
const blob = await exportImage({
url: img.url,
groupName: '生图结果',
variantName: img.variantName,
key: img.name || img.key,
});
const name = cleanFilename(img.name || img.key) || '生图结果';
const suffix = (() => {
try {
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
} catch {
return '.jpg';
}
})();
downloadBlob(blob, /\.(jpe?g|png|webp|gif|bmp)$/i.test(name) ? name : `${name}${suffix}`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setDownloadingResultKeys((prev) => {
const next = new Set(prev);
next.delete(img.key);
return next;
});
}
};
const handlePreviewDownload = (url: string) => {
// 预览中的地址就是当前展示版本:开启水印后,这里自然下载水印版。
void handleDownloadResultImage({
url,
name: '图片预览',
key: `preview-${url}`,
variantName: null,
});
};
const imagePreviewConfig: ImagePreviewConfig = {
actionsRender: (originalNode, info) => {
const actions = originalNode.props as {
children?: ReactNode;
className?: string;
style?: CSSProperties;
};
return (
<div className={actions.className} style={actions.style}>
{actions.children}
<button
type="button"
className="ant-image-preview-actions-action"
title="下载当前图片"
aria-label="下载当前图片"
onClick={(event) => {
event.stopPropagation();
handlePreviewDownload(info.image.url);
}}
>
<DownloadOutlined />
</button>
</div>
);
},
};
const handleApplyWatermark = (
assetIds: string[],
targetLabel: string,
resultItems: typeof selectedWatermarkItems = [],
) => {
if (!watermark.enabled) {
message.warning('请先在水印设置中开启水印');
return;
}
if (assetIds.length === 0) {
message.warning(`请先勾选已落库的${targetLabel}`);
return;
}
modal.confirm({
title: '批量添加水印',
content: `将按当前设置为 ${assetIds.length} 张${targetLabel}生成水印版。原图会保留,用于以后更改水印设置。`,
okText: `添加水印(${assetIds.length})`,
cancelText: '取消',
onOk: async () => {
setApplyingWatermark(true);
try {
const result = await applyWatermarkToAssets({
asset_ids: assetIds,
watermark,
});
const suiteKeysToHide = resultItems
.filter((img) => img.source === 'suite' && img.assetId)
.map((img) => img.key);
if (suiteKeysToHide.length > 0) {
setHiddenResultKeys((prev) => new Set([...prev, ...suiteKeysToHide]));
}
if (resultItems.length > 0) {
setSelectedResultKeys((prev) => {
const next = new Set(prev);
resultItems.forEach((img) => next.delete(img.key));
return next;
});
}
message.info(`水印任务已提交:${result.total} 张图片将在后台处理`);
pollWatermarkJob(result.job_id);
} catch (e) {
setApplyingWatermark(false);
message.error(apiErrorMessage(e));
}
},
});
};
const handleRemoveWatermark = (
assetIds: string[],
targetLabel: string,
resultItems: typeof selectedWatermarkItems = [],
) => {
if (assetIds.length === 0) {
message.warning(`请先勾选已加水印的${targetLabel}`);
return;
}
modal.confirm({
title: '移除水印',
content: `将为 ${assetIds.length} 张${targetLabel}恢复无水印原图,并删除当前水印版。`,
okText: `移除水印(${assetIds.length})`,
cancelText: '取消',
onOk: async () => {
setApplyingWatermark(true);
try {
const result = await removeWatermarkFromAssets(assetIds);
const updated = new Set(result.updated_ids);
const suiteKeysToHide = resultItems
.filter((img) => img.source === 'suite' && img.assetId && updated.has(img.assetId))
.map((img) => img.key);
if (suiteKeysToHide.length > 0) {
setHiddenResultKeys((prev) => new Set([...prev, ...suiteKeysToHide]));
}
onRefreshAssets();
result.failed.length > 0
? message.warning(`已移除 ${result.updated_ids.length} 张水印,${result.failed.length} 张失败`)
: message.success(`已移除 ${result.updated_ids.length} 张图片水印`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setApplyingWatermark(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 === 'done').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 = (
<TrialWatermarkSettings
watermark={watermark}
watermarkJob={watermarkJob}
applying={applyingWatermark}
selectedCollectionCount={selectedCollectionWatermarkAssetIds.length}
selectedResultCount={selectedWatermarkAssetIds.length}
selectedWatermarkedCollectionCount={selectedWatermarkedCollectionIds.length}
selectedWatermarkedResultCount={selectedWatermarkedResultIds.length}
onPatch={patchWatermark}
onApplyCollection={() => handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')}
onApplyResult={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)}
onRemoveCollection={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')}
onRemoveResult={() => handleRemoveWatermark(
selectedWatermarkedResultIds,
'生成结果图片',
selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled),
)}
onCancelJob={() => void handleCancelWatermarkJob()}
onRetryJob={() => void handleRetryWatermarkJob()}
onRetryItem={(itemId) => void handleRetryWatermarkItem(itemId)}
/>
);
const selectableCount = imgs.length;
const allResultSelected = resultImages.length > 0 && resultImages.every((img) => selectedResultKeys.has(img.key));
const toggleResultKey = (key: string) => {
setSelectedResultKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAllResults = (on: boolean) => {
setSelectedResultKeys((prev) => {
const next = new Set(prev);
resultImages.forEach((img) => (on ? next.add(img.key) : next.delete(img.key)));
return next;
});
};
/** 删除一张素材(二次确认后调用),成功后刷新素材列表并清掉相应选择。 */
const handleDeleteAsset = async (assetId: string, hiddenKeys: string[] = []) => {
try {
await deleteAsset(assetId);
setSelectedKeys((prev) => {
const next = new Set(prev);
next.delete(assetId);
return next;
});
setSelectedResultKeys((prev) => {
const next = new Set(prev);
next.delete(`asset-${assetId}`);
hiddenKeys.forEach((key) => next.delete(key));
return next;
});
if (hiddenKeys.length > 0) {
setHiddenResultKeys((prev) => new Set([...prev, ...hiddenKeys]));
}
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}
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>水印</Button>
</Popover>
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingImages}
onClick={handleExportImages}
>
下载采集图片
</Button>
</Space>
}
>
{displayGroups.length === 0 ? (
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
) : (
<CollectionImageGroups
groups={displayGroups.map((group) => ({ key: group, assets: groupImages(group) }))}
selectedKeys={selectedKeys}
preview={imagePreviewConfig}
imageUrl={assetUrl}
imageName={assetName}
onToggle={toggleKey}
onToggleGroup={toggleGroup}
onDelete={(assetId) => void handleDeleteAsset(assetId)}
onEdit={(asset) => setGenModal({ url: assetOriginalUrl(asset), name: asset.variant_name || asset.id })}
/>
)}
</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 生成结果(已选 ${selectedResultCount} / ${resultImages.length})`}
extra={
<Space>
{resultImages.length > 0 && (
<Button size="small" onClick={() => toggleAllResults(!allResultSelected)}>
{allResultSelected ? '取消全选' : '全选'}
</Button>
)}
{/* 水印设置:可作用于后续生成,或批量应用到当前已选生成图 */}
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
水印
</Button>
</Popover>
{/* 仅导出勾选的套图/单张生图结果 */}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingResult}
disabled={resultImages.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 === 'queued'
? '排队中'
: suite.status === 'done'
? '完成'
: suite.status === 'partial'
? '部分失败'
: suite.status === 'cancelled'
? '已取消'
: '失败'}
</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>}
{suite.status === 'partial' || suite.status === 'failed' ? (
<Button size="small" onClick={() => void handleRetrySuite()}>重试失败项</Button>
) : null}
{['queued', 'running'].includes(suite.status) ? (
<Popconfirm
title="取消生成任务"
description="未提交或等待模型结果的图片将停止回写;已完成图片会保留。"
okText="取消任务"
cancelText="继续生成"
okButtonProps={{ danger: true }}
onConfirm={() => void handleCancelSuite()}
>
<Button size="small" danger>取消任务</Button>
</Popconfirm>
) : null}
</Space>
</div>
<GeneratedImageGrid
images={suiteResultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
onToggle={toggleResultKey}
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key, `asset-${image.assetId}`])}
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
{historyResultImages.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 }}>
(一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张)
</Text>
</Text>
<div style={{ marginTop: 10 }}>
<GeneratedImageGrid
images={historyResultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
onToggle={toggleResultKey}
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
</div>
</div>
)}
</>
)}
</Card>
</div>
{/* ── 7 视频制作 ── */}
<div id="section-video">
<Card title="7 视频制作">
<TrialVideoMaker productId={product.id} assets={assets} />
</Card>
</div>
{/* 单张 AI 生图弹窗 */}
<AiImageGenModal
open={genModal !== null}
productId={product.id}
source={genModal}
watermark={watermark.enabled ? watermark : undefined}
onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()}
onSubmitted={(suiteId) => {
setGenerating(true);
pollSuite(suiteId);
}}
/>
</div>
);
}