1335 lines
62 KiB
TypeScript
1335 lines
62 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|