feat(edit): 优化图片导入ozon问题
This commit is contained in:
@@ -42,6 +42,7 @@ import { ProductImageSuiteDrawer } from './components/ProductImageSuiteDrawer';
|
||||
import type { SkuEditorImage } from './components/SkuImageCard';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
|
||||
import { composeSkuOfferId, lockedSkuOfferIds, skuOfferSuffix, syncSkuOfferIds } from './skuOfferId';
|
||||
import { useDraftSaveShortcut } from './useDraftSaveShortcut';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
@@ -498,6 +499,8 @@ const ProductEditPage = () => {
|
||||
const [catalogRefresh, setCatalogRefresh] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingLock = useRef(false);
|
||||
useDraftSaveShortcut(!!snapshot && !!currentPlatform && !loading && !saving, () => form.submit());
|
||||
const [savingSourceDescription, setSavingSourceDescription] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [publishStatusContainer, setPublishStatusContainer] = useState<HTMLDivElement | null>(null);
|
||||
@@ -902,7 +905,7 @@ const ProductEditPage = () => {
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
values = { ...values, ...form.getFieldsValue(true) };
|
||||
if (!productId || !currentPlatform || !snapshot) return false;
|
||||
if (!productId || !currentPlatform || !snapshot || savingLock.current) return false;
|
||||
const draft = normalizedValues({
|
||||
...values,
|
||||
ai_source_material: sourceDraft ?? snapshot.source_material,
|
||||
@@ -922,6 +925,7 @@ const ProductEditPage = () => {
|
||||
message.error('完整货号(含型号前缀)最多 50 字符,请缩短型号或 SKU 后缀');
|
||||
return false;
|
||||
}
|
||||
savingLock.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
@@ -959,6 +963,7 @@ const ProductEditPage = () => {
|
||||
} else message.error(apiErrorMessage(error));
|
||||
return false;
|
||||
} finally {
|
||||
savingLock.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
@@ -1699,6 +1704,8 @@ const ProductEditPage = () => {
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
icon={<SaveOutlined />}
|
||||
title="保存草稿(Command / Ctrl + S)"
|
||||
aria-keyshortcuts="Meta+S Control+S"
|
||||
>
|
||||
保存草稿
|
||||
</Button>
|
||||
|
||||
@@ -216,8 +216,10 @@ export function ProductPublishActions({
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const hasImportWarnings = job?.items.some((item) => item.status === 'imported' && item.errors.length > 0);
|
||||
const resultLabel = job?.status === 'succeeded' && hasImportWarnings ? '已导入,有提示' : job ? jobLabels[job.status] : '';
|
||||
const statusTone = job?.status === 'succeeded'
|
||||
? 'success'
|
||||
? hasImportWarnings ? 'warning' : 'success'
|
||||
: job?.status === 'failed'
|
||||
? 'error'
|
||||
: job?.status === 'partially_succeeded' || job?.status === 'reconciling'
|
||||
@@ -243,14 +245,14 @@ export function ProductPublishActions({
|
||||
type="button"
|
||||
className={`editor-publish-status editor-publish-status--${statusTone}`}
|
||||
onClick={showJob}
|
||||
aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`}
|
||||
aria-label={`查看发布任务详情:${resultLabel},${job.item_count} 个 SKU`}
|
||||
>
|
||||
<span className="editor-publish-status-icon" aria-hidden="true">
|
||||
{statusIcon}
|
||||
</span>
|
||||
<span className="editor-publish-status-content">
|
||||
<span className="editor-publish-status-title">
|
||||
发布任务 · {jobLabels[job.status]}
|
||||
发布任务 · {resultLabel}
|
||||
</span>
|
||||
<span className="editor-publish-status-count">{job.item_count} 个 SKU</span>
|
||||
</span>
|
||||
@@ -350,18 +352,20 @@ export function ProductPublishActions({
|
||||
type={
|
||||
job.status === 'failed'
|
||||
? 'error'
|
||||
: job.status === 'reconciling'
|
||||
: job.status === 'reconciling' || job.status === 'partially_succeeded' || hasImportWarnings
|
||||
? 'warning'
|
||||
: terminal(job)
|
||||
? 'success'
|
||||
: 'info'
|
||||
}
|
||||
showIcon
|
||||
title={`${jobLabels[job.status]} · ${job.item_count} 个 SKU`}
|
||||
title={`${resultLabel} · ${job.item_count} 个 SKU`}
|
||||
description={
|
||||
job.status === 'reconciling'
|
||||
? '平台受理结果尚未确认,系统将继续对账,请勿重复导入。'
|
||||
: '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。'
|
||||
: hasImportWarnings
|
||||
? '商品已导入,部分项目仍有提示,请按下方结果说明检查图片或商品资料。'
|
||||
: '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
@@ -378,13 +382,13 @@ export function ProductPublishActions({
|
||||
<Tag
|
||||
color={
|
||||
i.status === 'imported'
|
||||
? 'green'
|
||||
? i.errors.length ? 'gold' : 'green'
|
||||
: i.status === 'failed'
|
||||
? 'red'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{itemLabels[i.status]}
|
||||
{i.status === 'imported' && i.errors.length ? '已导入(有提示)' : itemLabels[i.status]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -694,20 +694,11 @@ export function ProductVariantSection({
|
||||
[image],
|
||||
collectionRecord?.id ?? null
|
||||
);
|
||||
const derivative = await createWatermarkedImage(
|
||||
return createWatermarkedImage(
|
||||
assetId,
|
||||
template.id,
|
||||
snapshot.product.id
|
||||
);
|
||||
const asset = derivative.output_asset;
|
||||
if (!asset) throw new Error('水印处理结果不可用');
|
||||
return {
|
||||
id: `asset:${asset.id}`,
|
||||
url: asset.preview_url,
|
||||
label: `${image.label} · ${template.name}`,
|
||||
origin: 'derivative',
|
||||
assetId: asset.id,
|
||||
};
|
||||
}}
|
||||
onResolveAsset={async (image) => {
|
||||
const [assetId] = await resolveSkuImageSelection(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { App as AntdApp, Alert, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
|
||||
import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkDerivative, WatermarkTemplate } from '@atlas/contracts';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { mediaApi } from '@/services/media';
|
||||
import { watermarksApi } from '@/services/watermarks';
|
||||
@@ -29,7 +29,7 @@ type Props = {
|
||||
onConfirm: (images: SkuEditorImage[]) => Promise<void>;
|
||||
onUploadFile: (file: File) => Promise<SkuEditorImage>;
|
||||
onMakeThreeFour: (image: SkuEditorImage) => Promise<SkuEditorImage>;
|
||||
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<SkuEditorImage>;
|
||||
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<WatermarkDerivative>;
|
||||
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
|
||||
onImageDeleted?: (image: SkuEditorImage) => void;
|
||||
onDownloadImage: (image: SkuEditorImage) => Promise<void>;
|
||||
@@ -82,7 +82,8 @@ export function SkuImageEditorModal({
|
||||
const [processingThreeFour, setProcessingThreeFour] = useState(false);
|
||||
const [processingWatermark, setProcessingWatermark] = useState(false);
|
||||
const [watermarkTargets, setWatermarkTargets] = useState<SkuEditorImage[]>([]);
|
||||
const [watermarkUndo, setWatermarkUndo] = useState<Record<string, SkuEditorImage | null>>({});
|
||||
const [watermarkUndo, setWatermarkUndo] = useState<Record<string, SkuEditorImage>>({});
|
||||
const [watermarkReplacements, setWatermarkReplacements] = useState<Record<string, SkuEditorImage>>({});
|
||||
const [aiTarget, setAiTarget] = useState<SkuEditorImage | null>(null);
|
||||
const [aiImages, setAiImages] = useState<SkuEditorImage[]>([]);
|
||||
const [processedRecords, setProcessedRecords] = useState<ProductImageDerivativeRecord[]>([]);
|
||||
@@ -97,8 +98,11 @@ export function SkuImageEditorModal({
|
||||
assetId: task.output_asset.id,
|
||||
}] : []));
|
||||
}, [productId]);
|
||||
const processedRequestVersion = useRef(0);
|
||||
const refreshProcessedImages = useCallback(async () => {
|
||||
const version = ++processedRequestVersion.current;
|
||||
const records = await mediaApi.listProductImageDerivatives(productId);
|
||||
if (version !== processedRequestVersion.current) return;
|
||||
setProcessedRecords(records);
|
||||
setProcessedError(false);
|
||||
}, [productId]);
|
||||
@@ -136,6 +140,9 @@ export function SkuImageEditorModal({
|
||||
setPreview(null);
|
||||
setWatermarkTargets([]);
|
||||
setWatermarkUndo({});
|
||||
setWatermarkReplacements({});
|
||||
setProcessedRecords([]);
|
||||
setProcessedError(false);
|
||||
setAiTarget(null);
|
||||
return () => {
|
||||
if (editorSession.current === session) {
|
||||
@@ -143,6 +150,7 @@ export function SkuImageEditorModal({
|
||||
deleteDialog.current?.destroy();
|
||||
deleteDialog.current = null;
|
||||
defaultWatermarkRequest.current = null;
|
||||
processedRequestVersion.current += 1;
|
||||
}
|
||||
};
|
||||
}, [open, productId, skuKey]);
|
||||
@@ -276,45 +284,60 @@ export function SkuImageEditorModal({
|
||||
if (processingLock.current || !targets.length) return;
|
||||
processingLock.current = true;
|
||||
setProcessingWatermark(true);
|
||||
const session = editorSession.current;
|
||||
let succeeded = 0;
|
||||
let selectedCount = images.length;
|
||||
const selectedAssetIds = new Set(images.flatMap((image) => image.assetId ? [image.assetId] : []));
|
||||
try {
|
||||
for (const image of targets) {
|
||||
const originalSelected = images.some((item) => sameImage(item, image));
|
||||
if (!originalSelected && selectedCount >= MAX_IMAGES) {
|
||||
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const output = await onApplyWatermark(image, template);
|
||||
const derivative = await onApplyWatermark(image, template);
|
||||
if (session !== editorSession.current) return;
|
||||
const asset = derivative.output_asset;
|
||||
if (!asset) throw new Error('水印处理结果不可用');
|
||||
const output: SkuEditorImage = {
|
||||
id: `asset:${asset.id}`, url: asset.preview_url,
|
||||
label: `${image.label} · ${template.name}`, origin: 'derivative', assetId: asset.id,
|
||||
};
|
||||
// 成功产物立即归档,较早发出的列表请求不得覆盖本次结果。
|
||||
processedRequestVersion.current += 1;
|
||||
setProcessedRecords((current) => [{
|
||||
id: derivative.id, kind: 'watermark', source_asset_id: derivative.source_asset_id,
|
||||
created_at: asset.created_at, output_asset: asset,
|
||||
}, ...current.filter((record) => record.id !== derivative.id)]);
|
||||
setProcessedError(false);
|
||||
setWatermarkReplacements((current) => {
|
||||
const sourceId = Object.keys(current).find((id) => sameImage(current[id], image)) ?? image.id;
|
||||
return { ...current, [sourceId]: output };
|
||||
});
|
||||
setWatermarkUndo((current) => ({
|
||||
...current, [output.id]: current[image.id] ?? image,
|
||||
}));
|
||||
succeeded += 1;
|
||||
if (output.assetId && selectedAssetIds.has(output.assetId)
|
||||
&& output.assetId !== image.assetId) {
|
||||
message.info(`${image.label} 的水印图片已在已选区,保留原图`);
|
||||
continue;
|
||||
}
|
||||
if (originalSelected && image.assetId) selectedAssetIds.delete(image.assetId);
|
||||
const originalSelected = images.some((item) => sameImage(item, image));
|
||||
// 候选图处理只更新当前卡片;已选图仅替换原位置,不自动增加选图。
|
||||
if (!originalSelected) continue;
|
||||
if (image.assetId) selectedAssetIds.delete(image.assetId);
|
||||
if (output.assetId) selectedAssetIds.add(output.assetId);
|
||||
if (!originalSelected) selectedCount += 1;
|
||||
setImages((current) => {
|
||||
const index = current.findIndex((item) => sameImage(item, image));
|
||||
if (index < 0) return [...current, output].slice(0, MAX_IMAGES);
|
||||
if (index < 0) return current;
|
||||
const next = [...current];
|
||||
next[index] = output;
|
||||
return next;
|
||||
});
|
||||
setMarkedIds((current) => current.map((id) => id === image.id ? output.id : id));
|
||||
setWatermarkUndo((current) => ({
|
||||
...current,
|
||||
[output.id]: image.id in current ? current[image.id] : originalSelected ? image : null,
|
||||
}));
|
||||
succeeded += 1;
|
||||
} catch (error) {
|
||||
if (session !== editorSession.current) return;
|
||||
message.error(`${image.label} 添加水印失败:${apiErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
if (succeeded) {
|
||||
message.success(`已生成 ${succeeded} 张水印图片,请确认后保存草稿`);
|
||||
message.success(`已生成 ${succeeded} 张水印图片,已存入图片处理记录`);
|
||||
void refreshProcessedImages().catch(() => setProcessedError(true));
|
||||
}
|
||||
else throw new Error('没有图片成功添加水印,请检查模板和素材后重试');
|
||||
@@ -351,12 +374,11 @@ export function SkuImageEditorModal({
|
||||
const undoWatermark = (image: SkuEditorImage) => {
|
||||
if (processingLock.current || !(image.id in watermarkUndo)) return;
|
||||
const original = watermarkUndo[image.id];
|
||||
setImages((current) => original
|
||||
? current.map((item) => item.id === image.id ? original : item)
|
||||
: current.filter((item) => item.id !== image.id));
|
||||
setMarkedIds((current) => original
|
||||
? current.map((id) => id === image.id ? original.id : id)
|
||||
: current.filter((id) => id !== image.id));
|
||||
setImages((current) => current.map((item) => item.id === image.id ? original : item));
|
||||
setMarkedIds((current) => current.map((id) => id === image.id ? original.id : id));
|
||||
setWatermarkReplacements((current) => Object.fromEntries(
|
||||
Object.entries(current).filter(([, output]) => !sameImage(output, image))
|
||||
));
|
||||
setWatermarkUndo((current) => {
|
||||
const next = { ...current };
|
||||
delete next[image.id];
|
||||
@@ -530,7 +552,8 @@ export function SkuImageEditorModal({
|
||||
const showPreview = (items: SkuEditorImage[], index: number) =>
|
||||
setPreview({ images: items, index });
|
||||
const renderSourceImages = (candidates: SkuEditorImage[]) => {
|
||||
const items = candidates.filter(image => !removedImages.some(removed => sameImage(removed, image)));
|
||||
const items = candidates.map((image) => watermarkReplacements[image.id] ?? image)
|
||||
.filter(image => !removedImages.some(removed => sameImage(removed, image)));
|
||||
return items.length ? (
|
||||
<div className="sku-image-source-grid">
|
||||
{items.map((image, index) => {
|
||||
@@ -562,7 +585,7 @@ export function SkuImageEditorModal({
|
||||
}));
|
||||
const renderProcessedImages = () => {
|
||||
if (processedLoading) return <div className="sku-image-processed-status"><Spin /></div>;
|
||||
if (processedError) return <Empty description="图片处理记录加载失败">
|
||||
if (processedError && !visibleProcessedRecords.length) return <Empty description="图片处理记录加载失败">
|
||||
<Button onClick={() => void refreshProcessedImages().catch(() => setProcessedError(true))}>重试</Button>
|
||||
</Empty>;
|
||||
if (!visibleProcessedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/** 页面保存复用表单提交;弹窗内尚未确认的编辑不应触发背景草稿保存。 */
|
||||
export function useDraftSaveShortcut(enabled: boolean, onSave: () => void) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey
|
||||
|| (event.code !== 'KeyS' && event.key.toLowerCase() !== 's')) return;
|
||||
if (event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
if (!enabled || event.repeat || event.isComposing) return;
|
||||
const hasDialog = [...document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]')]
|
||||
.some((dialog) => dialog.getClientRects().length > 0 && getComputedStyle(dialog).visibility !== 'hidden');
|
||||
if (!hasDialog) onSave();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [enabled, onSave]);
|
||||
}
|
||||
Reference in New Issue
Block a user