feat: AI 生图、图片下载优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user