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
+78 -28
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, type Key } from 'react';
import { useNavigate } from 'react-router';
import { Button, Card, Input, message, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -43,6 +43,7 @@ export default function CollectionPage() {
const res = await listProducts({ stage, q: q || undefined, page, page_size: pageSize });
setItems(res.items);
setTotal(res.total);
setSelectedKeys([]);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
@@ -76,6 +77,26 @@ export default function CollectionPage() {
}
};
// 勾选与当前列表视图绑定:每次 load 后清空,避免残留不可见商品的勾选
const [selectedKeys, setSelectedKeys] = useState<Key[]>([]);
const allSelected = items.length > 0 && selectedKeys.length === items.length;
const toggleSelectAll = () => {
setSelectedKeys(allSelected ? [] : items.map((i) => i.id));
};
const onBatchDelete = async () => {
const ids = [...selectedKeys];
const results = await Promise.allSettled(ids.map((id) => deleteProduct(String(id), true)));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail > 0) message.warning(`已删除 ${ok} 个商品,${fail} 个失败`);
else message.success(`已删除 ${ok} 个商品`);
setSelectedKeys([]);
// 当前页删空时回退一页,避免停在空页
if (ok > 0 && ok === items.length && page > 1) setPage(page - 1);
else load();
};
const columns: ColumnsType<ProductListItem> = [
{
title: '商品名',
@@ -140,38 +161,67 @@ export default function CollectionPage() {
<Title level={4} style={{ marginTop: 0 }}>
采集箱
</Title>
<Space style={{ marginBottom: 16 }} wrap>
<Input.Search
placeholder="搜索名称 / 货号"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setQ(v);
}}
/>
<select
value={stage ?? ''}
onChange={(e) => {
setPage(1);
setStage(e.target.value || undefined);
}}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
>
<option value="">全部状态</option>
{Object.entries(STAGE_LABEL).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
<Button onClick={load}>刷新</Button>
</Space>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索名称 / 货号"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setQ(v);
}}
/>
<select
value={stage ?? ''}
onChange={(e) => {
setPage(1);
setStage(e.target.value || undefined);
}}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
>
<option value="">全部状态</option>
{Object.entries(STAGE_LABEL).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
<Button onClick={load}>刷新</Button>
</Space>
<Space>
<Button onClick={toggleSelectAll} disabled={items.length === 0}>
{allSelected ? '取消全选' : '全选'}
</Button>
<Popconfirm
title={`确定删除选中的 ${selectedKeys.length} 个商品?`}
disabled={selectedKeys.length === 0}
onConfirm={onBatchDelete}
>
<Button danger disabled={selectedKeys.length === 0}>
删除{selectedKeys.length > 0 ? `(${selectedKeys.length})` : ''}
</Button>
</Popconfirm>
</Space>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: (keys) => setSelectedKeys(keys),
}}
pagination={{
current: page,
pageSize,
+4 -10
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Button, Col, Input, message, Row, Typography } from 'antd';
import { Col, Input, message, Row, Typography } from 'antd';
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { copyText } from '@/utils/file';
@@ -137,15 +137,9 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
addonAfter={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{openUrl && (
<Button
size="small"
type="primary"
ghost
icon={<ExportOutlined />}
onClick={() => window.open(openUrl, '_blank', 'noopener')}
>
打开地址
</Button>
<a title="打开采买地址" onClick={() => window.open(openUrl, '_blank', 'noopener')}>
<ExportOutlined />
</a>
)}
<a title="复制采买地址" onClick={copyPurchaseUrl}>
<CopyOutlined />
+4 -5
View File
@@ -31,21 +31,20 @@ export default function TrialPage() {
const { id } = useParams();
const [product, setProduct] = useState<ProductDetail | null>(null);
const [assets, setAssets] = useState<ProductAsset[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [active, setActive] = useState(SECTIONS[0].id);
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
// 杀掉生图轮询并丢失勾选/方案状态(表现为「生成中看不到图、上传后页面刷新」)。
// 首屏的加载态由 product 为空时的 Spin 兜底。
const load = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const [p, a] = await Promise.all([getProduct(id), listAssets(id)]);
setProduct(p);
setAssets(a);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setLoading(false);
}
}, [id]);
@@ -83,7 +82,7 @@ export default function TrialPage() {
[id, product],
);
if (loading || !product) {
if (!product) {
return (
<div style={{ padding: 80, textAlign: 'center' }}>
<Spin size="large" />
+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>
+2 -3
View File
@@ -5,9 +5,8 @@ import { envConfig } from '@/config/env';
const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl,
timeout: 120000, // 图生图较慢,放宽到 120s
headers: {
'Content-Type': 'application/json',
},
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
// 导致 /api/materials/bytes 等表单接口 422(全部字段 missing)。对象请求体 axios 会自动补 JSON 头。
});
apiClient.interceptors.response.use(
+13 -12
View File
@@ -54,21 +54,22 @@ export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
};
/** 生图模型(服务端按模型名路由 provider,同 image-suite-studio) */
/** 生图模型(服务端按模型名路由 provider;同名模型多家中转时 value 用「模型@供应商」区分,label 带供应商名) */
export const IMAGE_MODEL_OPTIONS = [
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
{ value: 'wan2.7-image-pro', label: 'wan2.7-image-pro', desc: '异步精修,质感与细节更强,适合高质量电商大片' },
{ value: 'wan2.6-image', label: 'wan2.6-image', desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好' },
{ value: 'wan2.6-t2i', label: 'wan2.6-t2i', desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快' },
{ value: 'gpt-image-2', label: 'gpt-image-2', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
{ value: 'nano-banana', label: 'nano-banana', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
{ value: 'nano-banana-2', label: 'nano-banana-2', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
{ value: 'nano-banana-pro', label: 'nano-banana-pro', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro(阿里巴巴)', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
{ value: 'wan2.7-image-pro', label: 'wan2.7-image-pro(阿里巴巴)', desc: '异步精修,质感与细节更强,适合高质量电商大片' },
{ value: 'wan2.6-image', label: 'wan2.6-image(阿里巴巴)', desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好' },
{ value: 'wan2.6-t2i', label: 'wan2.6-t2i(阿里巴巴)', desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快' },
{ value: 'gpt-image-2', label: 'gpt-image-2(RightCode)', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip(RightCode)', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
{ value: 'nano-banana', label: 'nano-banana(RightCode)', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
{ value: 'nano-banana-2', label: 'nano-banana-2(RightCode)', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite(RightCode)', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
{ value: 'nano-banana-pro', label: 'nano-banana-pro(RightCode)', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
{ value: 'gpt-image-2@pcore', label: 'gpt-image-2(Pcore)', desc: 'Pcore 中转的 GPT 图像模型,异步任务出图,构图与图内文案渲染强' },
] as const;
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2-vip';
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2@pcore';
export interface WatermarkPayload {
enabled: boolean;