feat: 优化图片资源删除管理,生图任务列表管理

This commit is contained in:
2026-08-30 14:50:33 +08:00
parent 4ee667142b
commit 957289f717
46 changed files with 4180 additions and 524 deletions
+9
View File
@@ -33,3 +33,12 @@ body {
#root {
min-height: 100vh;
}
/* 图库预览的图片大多为 3:4;翻页按钮压在图片两侧,方便就近翻页。 */
.ant-image-preview .ant-image-preview-switch-prev {
inset-inline-start: max(16px, calc(50% - min(35vw, 52.5vh) + 80px)) !important;
}
.ant-image-preview .ant-image-preview-switch-next {
inset-inline-end: max(16px, calc(50% - min(35vw, 52.5vh) + 80px)) !important;
}
@@ -97,12 +97,19 @@ export default function CollectionPage() {
else load();
};
/** 采集箱优先展示可编辑的中文标题,兼容早期采集数据;没有中文才回退俄文商品名。 */
const displayProductName = (item: ProductListItem) => {
const raw = item.raw ?? {};
const chineseTitle = String(raw.title_zh ?? raw.title ?? '').trim();
return chineseTitle || item.name?.trim() || '(未命名)';
};
const columns: ColumnsType<ProductListItem> = [
{
title: '商品名',
dataIndex: 'name',
render: (v, r) => (
<a onClick={() => navigate(`/trial/${r.id}`)}>{v || '(未命名)'}</a>
render: (_v, r) => (
<a onClick={() => navigate(`/trial/${r.id}`)}>{displayProductName(r)}</a>
),
},
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
+157 -42
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, imageEditSingle } from '@/services/suite';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, WatermarkPayload } from '@/services/suite';
const { Text } = Typography;
@@ -13,35 +14,121 @@ interface Props {
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
onSubmitted?: (suiteId: string) => void;
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated }: Props) {
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const activeTaskRef = useRef<string | null>(null);
// 每次打开重置(保留模型选择,方便连续精修)
const stopPolling = () => {
pollRunRef.current += 1;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current?.abort();
pollAbortRef.current = null;
};
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
useEffect(() => {
if (open) {
if (open && !activeTaskRef.current) {
setPrompt('');
setResultUrl('');
setGenerating(false);
setTaskId('');
setTaskError('');
}
}, [open]);
useEffect(() => () => stopPolling(), []);
const pollTask = (suiteId: string) => {
stopPolling();
const runId = ++pollRunRef.current;
const controller = new AbortController();
pollAbortRef.current = controller;
activeTaskRef.current = suiteId;
const startedAt = Date.now();
let failures = 0;
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current = null;
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
};
const poll = async () => {
if (runId !== pollRunRef.current || controller.signal.aborted) return;
try {
const suite = await getSuite(suiteId, { signal: controller.signal });
if (runId !== pollRunRef.current) return;
failures = 0;
if (['done', 'partial', 'failed', 'cancelled'].includes(suite.status)) {
finish();
const result = suite.images.find((image) => image.status === 'done' && image.url);
if (result) {
setResultUrl(result.url);
setTaskError(suite.error || '');
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(result.url);
} else if (suite.status === 'cancelled') {
setTaskError('任务已取消');
message.info('任务已取消');
} else {
const detail = suite.error || '生成失败,未返回图片';
setTaskError(detail);
message.error(detail);
}
return;
}
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
if (Date.now() - startedAt > 6 * 60_000) {
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
}
schedule(3000);
} catch (e) {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
failures += 1;
if (failures >= 3) {
finish();
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。';
setTaskError(detail);
message.error(detail);
return;
}
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
schedule(Math.min(3000 * (2 ** failures), 12_000));
}
};
schedule(0);
};
const onGenerate = async () => {
if (!source) return;
if (!prompt.trim()) {
message.warning('请先输入生图要求');
return;
}
setGenerating(true);
setResultUrl('');
setTaskError('');
try {
const r = await imageEditSingle({
product_id: productId,
@@ -50,17 +137,38 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
model,
append: true,
after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined,
});
setResultUrl(r.url);
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(r.url);
setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
if (onSubmitted) {
onSubmitted(r.suite_id);
onClose();
} else {
// 独立使用该组件时仍保留自身轮询能力。
pollTask(r.suite_id);
}
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setTaskError(apiErrorMessage(e));
setGenerating(false);
}
};
const onCancelTask = async () => {
if (!taskId) return;
try {
await cancelSuite(taskId);
stopPolling();
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
setTaskError('任务已取消;已完成的图片会保留在生成结果中。');
} catch (e) {
message.error(apiErrorMessage(e));
}
};
return (
<>
{open && (
@@ -88,15 +196,12 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Text type="secondary" style={{ fontSize: 12, maxWidth: 360 }} ellipsis={{ tooltip: source?.name }}>
{source?.name}
</Text>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Text type="secondary" style={{ fontSize: 12 }}>原图</Text>
<Image
src={source?.url}
alt="原图"
@@ -108,37 +213,47 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'}
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
disabled={generating}
/>
<Space style={{ marginTop: 12 }} wrap>
<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 style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 0 }}
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>
</div>
)}
/>
<Button type="primary" loading={generating} onClick={onGenerate}>
生成
</Button>
<Button onClick={onClose}>关闭</Button>
</Space>
)}
/>
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
提交任务
</Button>
</div>
</div>
<Alert
style={{ marginTop: 16 }}
type="info"
type={taskError && !generating ? 'error' : 'info'}
showIcon
message="生成结果会自动追加到「生成图」分组;GPT 系列单张 1-5 分钟,请耐心等待"
message={
generating
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
}
/>
{generating && taskId && (
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
取消此任务
</Button>
)}
</div>
</div>
{resultUrl && (
+27 -17
View File
@@ -5,7 +5,7 @@
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
*/
import { useCallback, useEffect, useState } from 'react';
import { Button, Input, message, Modal, Popconfirm, Table, Typography } from 'antd';
import { Button, Input, message, Modal, Popconfirm, Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
@@ -224,6 +224,26 @@ export default function TrialExportPanel({ product }: Props) {
dataIndex: field,
render: (v: unknown) => <Text style={{ fontSize: 13 }}>{fmt(v)}</Text>,
});
const renderLink = (url: string, maxWidth: number) => (
<Tooltip title={url}>
<a
href={url.startsWith('http') ? url : `https://${url}`}
target="_blank"
rel="noreferrer"
style={{
display: 'inline-block',
maxWidth,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
verticalAlign: 'bottom',
fontSize: 12,
}}
>
{url}
</a>
</Tooltip>
);
const base: ColumnsType<RegisterRecord> = [
{
title: '操作',
@@ -265,28 +285,17 @@ export default function TrialExportPanel({ product }: Props) {
title: 'Ozon地址',
dataIndex: 'sku',
width: 160,
render: (sku: string) =>
sku ? (
<a href={`https://www.ozon.ru/product/${sku}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
{`https://www.ozon.ru/product/${sku}`}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
render: (sku: string) => {
const url = sku ? `https://www.ozon.ru/product/${sku}` : '';
return url ? renderLink(url, 128) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>;
},
},
{
title: '采买地址',
dataIndex: 'purchaseUrl',
width: 180,
ellipsis: true,
render: (v: string) =>
v ? (
<a href={v.startsWith('http') ? v : `https://${v}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }} title={v}>
{v}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
v ? renderLink(v, 148) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>,
},
];
return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links];
@@ -306,6 +315,7 @@ export default function TrialExportPanel({ product }: Props) {
zIndex: 100,
background: COLOR_SECONDARY,
height: 40,
width: 124,
paddingInline: 16,
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
}
+40 -4
View File
@@ -4,15 +4,17 @@
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
*/
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
import { Link, useNavigate, useParams } from 'react-router';
import { DeleteOutlined } from '@ant-design/icons';
import { Button, Card, Menu, Popconfirm, Space, Spin, Typography, message } from 'antd';
import { deleteProduct, getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
import TrialInfoPanel from './TrialInfoPanel';
import TrialPricingPanel from './TrialPricingPanel';
import TrialSuitePanel from './TrialSuitePanel';
import TrialExportPanel from './TrialExportPanel';
import TrialTaskCenter from './TrialTaskCenter';
import CopyPanel from '../product/CopyPanel';
const { Title, Text } = Typography;
@@ -29,9 +31,11 @@ const SECTIONS = [
export default function TrialPage() {
const { id } = useParams();
const navigate = useNavigate();
const [product, setProduct] = useState<ProductDetail | null>(null);
const [assets, setAssets] = useState<ProductAsset[]>([]);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [active, setActive] = useState(SECTIONS[0].id);
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
@@ -82,6 +86,21 @@ export default function TrialPage() {
[id, product],
);
const deleteCurrentProduct = async () => {
if (!id) return;
setDeleting(true);
try {
// hard=true 会同时删除 original / 当前水印版等商品图片资源。
await deleteProduct(id, true);
message.success('商品及其图片资源已删除');
navigate('/collection', { replace: true });
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setDeleting(false);
}
};
if (!product) {
return (
<div style={{ padding: 80, textAlign: 'center' }}>
@@ -106,7 +125,21 @@ export default function TrialPage() {
{STAGE_LABEL[product.stage] || product.stage}
</span>
</Space>
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
<Space size={6}>
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
<Popconfirm
title="永久删除商品?"
description="将同时删除采集图、AI 图片和水印版本,删除后无法恢复。"
okText="永久删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={deleteCurrentProduct}
>
<Button type="text" danger size="small" icon={<DeleteOutlined />} loading={deleting}>
删除商品
</Button>
</Popconfirm>
</Space>
</div>
<Space size={16} style={{ marginTop: 4 }}>
{product.name && product.name !== titleZh && (
@@ -143,6 +176,9 @@ export default function TrialPage() {
</div>
</div>
{/* 固定操作坞:任务位于「录入商品」正上方,任务详情在右侧抽屉中统一管理。 */}
<TrialTaskCenter productId={product.id} onRefreshAssets={load} />
{/* 右侧区域导航 */}
<div style={{ width: 120, flexShrink: 0 }}>
<div style={{ position: 'sticky', top: 80 }}>
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
/** 当前商品的后台任务入口:固定操作坞 + 右侧任务抽屉。 */
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
Alert, Badge, Button, Card, Drawer, Empty, Popconfirm, Progress, Space, Tag, Typography, message,
} from 'antd';
import {
BgColorsOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
LoadingOutlined, PictureOutlined, ReloadOutlined, StopOutlined,
} from '@ant-design/icons';
import {
cancelSuite, listProductSuites, listProductWatermarkJobs, retrySuite, retrySuiteItem,
type SuiteImageInfo, type SuiteInfo,
} from '@/services/suite';
import { apiErrorMessage } from '@/services/api';
const { Text } = Typography;
const ACTIVE_STATUSES = new Set(['queued', 'pending', 'running']);
interface Props {
productId: string;
onRefreshAssets: () => void;
}
function taskTitle(task: SuiteInfo) {
if (task.kind === 'watermark_batch') return '批量添加水印';
if (task.kind === 'image_edit') return 'AI 生图 / 编辑';
return '电商套图生成';
}
function taskIcon(task: SuiteInfo) {
return task.kind === 'watermark_batch' ? <BgColorsOutlined /> : <PictureOutlined />;
}
function isActive(task: SuiteInfo) {
return ACTIVE_STATUSES.has(task.status);
}
function isWaitingProvider(task: SuiteInfo) {
return task.images.some((image) => image.status === 'waiting_provider');
}
function statusMeta(task: SuiteInfo): { label: string; color: string; icon?: ReactNode } {
if (isWaitingProvider(task)) return { label: '等待模型结果', color: 'processing', icon: <ClockCircleOutlined /> };
if (task.status === 'queued' || task.status === 'pending') return { label: '排队中', color: 'default', icon: <ClockCircleOutlined /> };
if (task.status === 'running') return { label: '处理中', color: 'processing', icon: <LoadingOutlined spin /> };
if (task.status === 'done') return { label: '已完成', color: 'success', icon: <CheckCircleOutlined /> };
if (task.status === 'partial') return { label: '部分完成', color: 'warning' };
if (task.status === 'cancelled') return { label: '已取消', color: 'default' };
return { label: '已失败', color: 'error', icon: <CloseCircleOutlined /> };
}
function formatTime(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
}).format(date);
}
function TaskCard({ task, onCancel, onRetry, onRetryItem, onOpenResult }: {
task: SuiteInfo;
onCancel: (task: SuiteInfo) => void;
onRetry: (task: SuiteInfo) => void;
onRetryItem: (task: SuiteInfo, item: SuiteImageInfo) => void;
onOpenResult: () => void;
}) {
const meta = statusMeta(task);
const failedItems = task.images.filter((image) => image.status === 'failed');
const total = task.total ?? task.images.length;
const completed = task.completed ?? task.images.filter((image) => image.status === 'done').length;
const canOpenResult = ['image_suite', 'image_edit'].includes(task.kind || '') && completed > 0;
return (
<Card
size="small"
style={{ marginBottom: 6, background: isActive(task) ? '#f6faff' : undefined }}
styles={{ body: { padding: '10px 12px' } }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<span style={{ color: task.kind === 'watermark_batch' ? '#722ed1' : '#1677ff', paddingTop: 3 }}>
{taskIcon(task)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<Text strong ellipsis={{ tooltip: taskTitle(task) }}>{taskTitle(task)}</Text>
<Tag color={meta.color} icon={meta.icon} style={{ marginInlineEnd: 0, flexShrink: 0 }}>{meta.label}</Tag>
</div>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 2 }}>
{task.kind === 'watermark_batch'
? `为 ${total} 张图片添加水印`
: task.kind === 'image_edit'
? '单张图片生成任务'
: `套图生成 · ${total} 张`}
{task.created_at ? ` · ${formatTime(task.created_at)}` : ''}
</Text>
{isActive(task) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
<Progress
percent={total ? Math.round((completed / total) * 100) : 0}
size="small"
status="active"
format={() => `${completed}/${total}`}
style={{ flex: 1, minWidth: 0 }}
/>
<Popconfirm
title="取消任务"
description="未完成的子项将停止;已经完成的结果会保留。"
okText="取消任务"
cancelText="继续处理"
okButtonProps={{ danger: true }}
onConfirm={() => onCancel(task)}
>
<Button size="small" danger icon={<StopOutlined />}>取消</Button>
</Popconfirm>
</div>
)}
{task.error && (
<Text type="danger" ellipsis={{ tooltip: task.error }} style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
{task.error}
</Text>
)}
{failedItems.slice(0, 3).map((item) => (
<div key={item.item_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
<Text type="danger" ellipsis={{ tooltip: item.error || item.name }} style={{ flex: 1, fontSize: 12 }}>
{item.name}:{item.error || '处理失败'}
</Text>
<Button size="small" onClick={() => onRetryItem(task, item)}>重试</Button>
</div>
))}
{failedItems.length > 3 && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>另有 {failedItems.length - 3} 项失败</Text>
)}
<Space size={6} style={{ marginTop: 6 }} wrap>
{['partial', 'failed'].includes(task.status) && (
<Button size="small" icon={<ReloadOutlined />} onClick={() => onRetry(task)}>重试失败项</Button>
)}
{canOpenResult && (
<Button size="small" type="link" onClick={onOpenResult}>查看结果</Button>
)}
</Space>
</div>
</div>
</Card>
);
}
export default function TrialTaskCenter({ productId, onRefreshAssets }: Props) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<SuiteInfo[]>([]);
const [loadError, setLoadError] = useState('');
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeIdsRef = useRef<Set<string>>(new Set());
const initializedRef = useRef(false);
const stopPolling = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
abortRef.current?.abort();
abortRef.current = null;
}, []);
const loadTasks = useCallback(async (signal?: AbortSignal) => {
const [imageTasks, watermarkTasks] = await Promise.all([
listProductSuites(productId, false, { signal }),
listProductWatermarkJobs(productId, false, { signal }),
]);
const byId = new Map<string, SuiteInfo>();
[...imageTasks, ...watermarkTasks].forEach((task) => byId.set(task.id, task));
const next = Array.from(byId.values()).sort((a, b) =>
(b.created_at || '').localeCompare(a.created_at || ''),
);
const nextActive = new Set(next.filter(isActive).map((task) => task.id));
if (initializedRef.current && [...activeIdsRef.current].some((id) => !nextActive.has(id))) {
onRefreshAssets();
}
initializedRef.current = true;
activeIdsRef.current = nextActive;
setTasks(next);
setLoadError('');
}, [onRefreshAssets, productId]);
useEffect(() => {
stopPolling();
initializedRef.current = false;
activeIdsRef.current = new Set();
let disposed = false;
let failures = 0;
const run = async () => {
if (disposed) return;
const controller = new AbortController();
abortRef.current = controller;
try {
await loadTasks(controller.signal);
failures = 0;
setLoading(false);
} catch (error) {
if (!controller.signal.aborted) {
failures += 1;
setLoadError(failures >= 3 ? '任务状态暂时无法刷新,请稍后重试' : '');
setLoading(false);
}
} finally {
if (abortRef.current === controller) abortRef.current = null;
if (!disposed) timerRef.current = setTimeout(() => void run(), failures ? Math.min(3000 * (2 ** failures), 15_000) : 3000);
}
};
void run();
return () => {
disposed = true;
stopPolling();
};
}, [loadTasks, productId, stopPolling]);
const activeTasks = useMemo(() => tasks.filter(isActive), [tasks]);
const recentTasks = useMemo(() => tasks.filter((task) => !isActive(task)).slice(0, 12), [tasks]);
const refreshNow = async () => {
setLoading(true);
try {
await loadTasks();
} catch (error) {
setLoadError(apiErrorMessage(error));
} finally {
setLoading(false);
}
};
const handleCancel = async (task: SuiteInfo) => {
try {
await cancelSuite(task.id);
message.info('任务已取消;已完成的结果会保留');
await refreshNow();
} catch (error) {
message.error(apiErrorMessage(error));
}
};
const handleRetry = async (task: SuiteInfo) => {
try {
await retrySuite(task.id);
message.info('失败项已重新加入队列');
await refreshNow();
} catch (error) {
message.error(apiErrorMessage(error));
}
};
const handleRetryItem = async (task: SuiteInfo, item: SuiteImageInfo) => {
try {
await retrySuiteItem(task.id, item.item_id);
message.info('该项已重新加入队列');
await refreshNow();
} catch (error) {
message.error(apiErrorMessage(error));
}
};
const openResult = () => {
setOpen(false);
window.setTimeout(() => document.getElementById('section-result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
};
return (
<>
<div style={{ position: 'fixed', right: 24, bottom: 72, zIndex: 100, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<Badge count={activeTasks.length} overflowCount={99} offset={[-2, 2]}>
<Button
icon={activeTasks.length > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />}
onClick={() => setOpen(true)}
style={{ width: 124, height: 40, borderColor: activeTasks.length ? '#91caff' : undefined, color: activeTasks.length ? '#1677ff' : undefined, boxShadow: '0 3px 10px rgba(0,0,0,0.12)' }}
>
{activeTasks.length ? `处理中(${activeTasks.length})` : '任务'}
</Button>
</Badge>
</div>
<Drawer
title={`当前商品任务${activeTasks.length ? ` · 处理中 ${activeTasks.length}` : ''}`}
open={open}
onClose={() => setOpen(false)}
width={460}
extra={<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={() => void refreshNow()}>刷新</Button>}
>
{loadError && <Alert type="warning" showIcon message={loadError} style={{ marginBottom: 12 }} />}
<Text strong>处理中{activeTasks.length ? `(${activeTasks.length})` : ''}</Text>
<div style={{ marginTop: 8 }}>
{activeTasks.length ? activeTasks.map((task) => (
<TaskCard key={task.id} task={task} onCancel={handleCancel} onRetry={handleRetry} onRetryItem={handleRetryItem} onOpenResult={openResult} />
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前没有处理中的任务" />}
</div>
{recentTasks.length > 0 && (
<>
<Text strong style={{ display: 'block', marginTop: 18, marginBottom: 8 }}>最近任务</Text>
{recentTasks.map((task) => (
<TaskCard key={task.id} task={task} onCancel={handleCancel} onRetry={handleRetry} onRetryItem={handleRetryItem} onOpenResult={openResult} />
))}
</>
)}
</Drawer>
</>
);
}
+6
View File
@@ -9,6 +9,8 @@ export interface ProductListItem {
currency_code: string;
source_platform: string | null;
source_url: string | null;
/** 采集中文标题保存在 raw.title_zh;raw.title 为早期数据兼容字段。 */
raw?: Record<string, unknown> | null;
asset_counts: Record<string, number> | null;
ozon_product_id: number | null;
created_at: string;
@@ -63,6 +65,10 @@ export interface ProductAsset {
type: string;
source_url: string;
stored_url: string | null;
/** 无水印原图;AI 参考图应优先使用该地址。 */
original_url?: string | null;
watermark_enabled?: boolean;
watermark_config?: Record<string, unknown> | null;
status: 'pending' | 'downloading' | 'uploaded' | 'failed';
width: number | null;
height: number | null;
+76 -11
View File
@@ -5,6 +5,7 @@
*/
import { api } from './api';
import apiClient from './api';
import type { AxiosRequestConfig } from 'axios';
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
export const SUITE_TYPE_OPTIONS = [
@@ -42,11 +43,11 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) =
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [
{ value: 1, label: '北欧极简', prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净' },
{ value: 2, label: '清新明亮', prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净' },
{ value: 3, label: '高级感深色', prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级' },
{ value: 4, label: '暖调生活', prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强' },
{ value: 5, label: '纯净棚拍', prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出' },
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
{ value: 2, label: '清新明亮', prompt: 'Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood' },
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent' },
] as const;
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
@@ -115,6 +116,7 @@ export interface SuiteGeneratePayload {
}
export interface SuiteImageInfo {
item_id: string;
type_id: string;
name: string;
url: string;
@@ -124,13 +126,20 @@ export interface SuiteImageInfo {
export interface SuiteInfo {
id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
status: 'queued' | 'pending' | 'running' | 'done' | 'partial' | 'failed' | 'cancelled';
kind?: 'image_suite' | 'image_edit' | string;
style_set: number;
platform: string;
lang: string;
ratio: string;
provider?: string;
total?: number;
completed?: number;
failed?: number;
cancel_requested?: boolean;
created_at?: string | null;
started_at?: string | null;
completed_at?: string | null;
images: SuiteImageInfo[];
error?: string | null;
}
@@ -146,8 +155,26 @@ export function generateSuite(payload: SuiteGeneratePayload) {
}
/** ★ 查询套图任务状态(轮询用) */
export function getSuite(suiteId: string) {
return api.get<SuiteInfo>(`/suites/${suiteId}`);
export function getSuite(suiteId: string, config?: AxiosRequestConfig) {
return api.get<SuiteInfo>(`/suites/${suiteId}`, config);
}
/** 当前商品的后台图片任务,用于刷新页面后恢复进度。 */
export function listProductSuites(productId: string, activeOnly = false, config?: AxiosRequestConfig) {
return api.get<SuiteInfo[]>(`/products/${productId}/suites`, { ...config, params: { active_only: activeOnly } });
}
export function cancelSuite(suiteId: string) {
return api.post<SuiteInfo>(`/suites/${suiteId}/cancel`);
}
export function retrySuite(suiteId: string) {
return api.post<SuiteInfo>(`/suites/${suiteId}/retry`);
}
/** 重试任务中单个失败图片;批量水印可据此只补偿失败项。 */
export function retrySuiteItem(suiteId: string, itemId: string) {
return api.post<SuiteInfo>(`/suites/${suiteId}/items/${itemId}/retry`);
}
/** ★ 下载生成结果 ZIP(blob) */
@@ -159,16 +186,18 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload {
product_id?: string;
image_url: string;
prompt: string;
prompt?: string;
model: string;
append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string;
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
export function imageEditSingle(payload: ImageEditSinglePayload) {
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
return api.post<{ suite_id: string }>('/suite/image-edit', payload);
}
/** 删除一条素材(DB 记录 + 本地文件),用于删除已生成的图片 */
@@ -176,6 +205,36 @@ export function deleteAsset(assetId: string) {
return api.delete<{ deleted: boolean }>(`/assets/${assetId}`);
}
export interface ApplyWatermarkPayload {
asset_ids: string[];
watermark: WatermarkPayload;
}
export interface ApplyWatermarkResponse {
job_id: string;
total: number;
}
export interface RemoveWatermarkResponse {
updated_ids: string[];
failed: Array<{ asset_id: string; error: string }>;
}
/** 将水印任务加入后台队列;Worker 始终从保留的原图重做,避免重复叠加。 */
export function applyWatermarkToAssets(payload: ApplyWatermarkPayload) {
return api.post<ApplyWatermarkResponse>('/assets/watermark', payload);
}
/** 取消已选图片的水印,服务端切回保留的无水印原图。 */
export function removeWatermarkFromAssets(assetIds: string[]) {
return api.post<RemoveWatermarkResponse>('/assets/watermark/remove', { asset_ids: assetIds });
}
/** 当前商品的批量水印任务,用于刷新页面后恢复进度。 */
export function listProductWatermarkJobs(productId: string, activeOnly = false, config?: AxiosRequestConfig) {
return api.get<SuiteInfo[]>(`/products/${productId}/watermark-jobs`, { ...config, params: { active_only: activeOnly } });
}
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
@@ -187,6 +246,12 @@ export async function exportImages(payload: ExportImagesPayload): Promise<Blob>
return res.data as Blob;
}
/** 单张图片下载:服务端代理源站图片,避免防盗链与跨域导致浏览器无法保存。 */
export async function exportImage(payload: ExportImagesPayload['images'][number]): Promise<Blob> {
const res = await apiClient.post('/export/image', payload, { responseType: 'blob' });
return res.data as Blob;
}
/** 上传补充参考图到商品素材(已有接口:POST /api/materials/bytes) */
export async function uploadProductAsset(
productId: string,
+1 -1
View File
@@ -6,7 +6,7 @@ import type { WatermarkState } from '@/types/image';
export const WATERMARK_SCALE = 0.15; // 图片水印直径占原图宽度的比例
export const WATERMARK_MARGIN = 10; // 默认位置距右下角的间距(原图像素)
export const WATERMARK_TEXT_SCALE = 0.051; // 文字水印字号占原图宽度的比例(原 0.06 的 85%)
export const WATERMARK_TEXT_SCALE = 0.0408; // 文字水印字号占原图宽度的比例(原 0.051 缩小 20%)
interface Size {
width: number;