feat: AI 生图、图片下载优化

This commit is contained in:
Joey
2026-08-29 17:13:06 +08:00
parent a77ec26a02
commit 4ee667142b
20 changed files with 683 additions and 248 deletions
+125 -59
View File
@@ -5,7 +5,7 @@
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popconfirm, Popover, Progress,
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
@@ -145,10 +145,10 @@ function loadWatermark(): WatermarkPayload {
}
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)),
);
// 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);
@@ -169,6 +169,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const [exportingZip, setExportingZip] = useState(false);
const [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重置后丢弃迟到的过期响应 */
@@ -182,6 +183,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]);
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
// 图名:取 URL 路径末段(采集图为源站文件名;上传图为落盘文件名),无路径时回退规格名
const assetName = (a: ProductAsset): string => {
const raw = a.source_url || a.stored_url || '';
try {
const seg = decodeURIComponent(new URL(raw, window.location.origin).pathname.split('/').pop() || '');
return seg || a.variant_name || '未命名';
} catch {
return a.variant_name || '未命名';
}
};
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
@@ -402,7 +413,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
message.warning('出图方案的张数都是 0');
return;
}
Modal.confirm({
modal.confirm({
title: '生成电商套图',
content: (
<div>
@@ -432,6 +443,38 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}
};
// ── 导出全部生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
const handleExportResult = async () => {
const suiteOk = (suite?.images ?? []).filter((i) => i.status === 'ok');
if (suiteOk.length === 0 && generatedAssets.length === 0) {
message.warning('暂无生图结果可导出');
return;
}
setExportingResult(true);
try {
const images = [
...suiteOk.map((img, i) => ({
url: img.url,
groupName: '套图结果',
variantName: null,
key: `suite-${String(i + 1).padStart(3, '0')}`,
})),
...generatedAssets.map((a, i) => ({
url: assetUrl(a),
groupName: '单张生图',
variantName: a.variant_name,
key: `single-${String(i + 1).padStart(3, '0')}`,
})),
];
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setExportingResult(false);
}
};
const setPlanCount = (idx: number, count: number) => {
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p)));
};
@@ -571,76 +614,89 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Text>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, minmax(0, 1fr))', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id} style={{ width: 112, position: 'relative' }}>
<div key={a.id}>
<div
style={{
position: 'relative',
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: on ? 'rgba(22,119,255,0.06)' : undefined,
width: 'fit-content',
}}
>
<Image
src={assetUrl(a)}
width={100}
height={100}
style={{ objectFit: 'cover', borderRadius: 6 }}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: on ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: on ? '#fff' : '#bbb',
border: '1px solid #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 2,
userSelect: 'none',
}}
>
✓
</span>
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
{/* AI 生图:半透明悬浮在图片右下角 */}
<Button
size="small"
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
style={{
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
background: 'rgba(0, 0, 0, 0.45)',
color: '#fff',
borderColor: 'transparent',
}}
>
AI 生图
</Button>
</div>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
<div
title={a.variant_name || assetName(a)}
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',
fontSize: 11,
color: '#999',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
✓
</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>
{a.variant_name || assetName(a)}
</div>
</div>
);
})}
@@ -854,6 +910,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
水印
</Button>
</Popover>
{/* 导出全部生图结果(套图 + 单张生图) */}
<Button
size="small"
icon={<DownloadOutlined />}
loading={exportingResult}
disabled={(!suite || !['done', 'partial'].includes(suite.status)) && generatedAssets.length === 0}
onClick={handleExportResult}
>
导出图片
</Button>
</Space>
}
>
@@ -950,9 +1016,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{generatedAssets.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
单张 AI 生图
生图记录
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(采集图片区「AI 生图」的结果 · {generatedAssets.length} 张)
(一键生成与单张生图的落库历史,刷新后仍保留 · {generatedAssets.length} 张)
</Text>
</Text>
<Image.PreviewGroup>