feat: 商品试算页
This commit is contained in:
@@ -0,0 +1,866 @@
|
||||
/**
|
||||
* 04 图片与 AI 生图(docs/v2.1/image-suite.md):
|
||||
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
||||
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Checkbox, Empty, Image, Input, InputNumber, message, Modal, 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,
|
||||
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';
|
||||
|
||||
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) {
|
||||
// 素材选择(默认全选主图 + SKU 图,SKU 规格名供 AI 规划 variant 绑定)
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
|
||||
() => new Set(assets.filter((a) => ['main', 'sku'].includes(a.group_key) && a.type !== 'video').map((a) => a.id)),
|
||||
);
|
||||
/** 上传成功待自动勾选的 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 [exportingImages, setExportingImages] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
/** 规划请求序号:重置后丢弃迟到的过期响应 */
|
||||
const planSeqRef = useRef(0);
|
||||
|
||||
// 单张 AI 生图弹窗
|
||||
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video'), [assets]);
|
||||
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
|
||||
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* ── 采集图片 ── */}
|
||||
<Card
|
||||
size="small"
|
||||
title={`采集图片(已选 ${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: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{groupImages(g).map((a) => {
|
||||
const on = selectedKeys.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ width: 112, position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
background: on ? 'rgba(22,119,255,0.06)' : undefined,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(a)}
|
||||
width={100}
|
||||
height={100}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
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>"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
title="勾选为参考底图"
|
||||
onClick={() => toggleKey(a.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 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: 1,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{a.variant_name && (
|
||||
<Tag
|
||||
style={{ position: 'absolute', top: 6, right: 6, margin: 0, zIndex: 1 }}
|
||||
>
|
||||
{a.variant_name}
|
||||
</Tag>
|
||||
)}
|
||||
{a.status !== 'uploaded' && (
|
||||
<Tag
|
||||
color={a.status === 'failed' ? 'red' : 'default'}
|
||||
style={{ position: 'absolute', bottom: 40, left: 6, margin: 0, zIndex: 1 }}
|
||||
>
|
||||
{a.status === 'failed' ? '转存失败' : '转存中'}
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
style={{ marginTop: 4 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
|
||||
>
|
||||
AI 生图
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── 出图方案 ── */}
|
||||
<Card
|
||||
size="small"
|
||||
title={`出图方案(共 ${totalPlanned} 张)`}
|
||||
extra={
|
||||
<Space>
|
||||
{planSource === 'ai' && <Tag color="purple">AI 方案</Tag>}
|
||||
<Checkbox
|
||||
checked={planThenGenerate}
|
||||
onChange={(e) => setPlanThenGenerate(e.target.checked)}
|
||||
title="勾选后,AI 规划完成将自动开始生成"
|
||||
>
|
||||
规划并生成
|
||||
</Checkbox>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={planning}
|
||||
onClick={handlePlan}
|
||||
>
|
||||
AI 智能规划
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<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()}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={5}
|
||||
value={p.count}
|
||||
onChange={(v) => setPlanCount(idx, v ?? 0)}
|
||||
/>
|
||||
</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 }} ellipsis={{ tooltip: planSummary }}>
|
||||
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Text style={{ width: 70 }}>视觉风格</Text>
|
||||
<Segmented
|
||||
value={styleSet}
|
||||
onChange={(v) => setStyleSet(v as number)}
|
||||
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
|
||||
/>
|
||||
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
|
||||
<Button size="small" icon={<SettingOutlined />}>
|
||||
水印
|
||||
</Button>
|
||||
</Popover>
|
||||
</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={2}
|
||||
value={requirements}
|
||||
onChange={(e) => setRequirements(e.target.value)}
|
||||
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
|
||||
/>
|
||||
</div>
|
||||
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}>
|
||||
{generating && (
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Progress
|
||||
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
|
||||
size={['100%', 10]}
|
||||
status="active"
|
||||
format={() => `${doneCount}/${suiteTotal}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── 生成结果 ── */}
|
||||
<Card
|
||||
size="small"
|
||||
title="生成结果"
|
||||
extra={
|
||||
suite && ['done', 'partial'].includes(suite.status) && (
|
||||
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
|
||||
导出 ZIP
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!suite ? (
|
||||
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
|
||||
) : (
|
||||
<>
|
||||
<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 }} />}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 单张 AI 生图弹窗 */}
|
||||
<AiImageGenModal
|
||||
open={genModal !== null}
|
||||
productId={product.id}
|
||||
source={genModal}
|
||||
onClose={() => setGenModal(null)}
|
||||
onGenerated={() => onRefreshAssets()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user