feat: 完成 SKU 图片选择及素材导入后端
This commit is contained in:
@@ -1062,7 +1062,7 @@ const ProductEditPage = () => {
|
||||
modal.info({
|
||||
title: `${name}将在下一阶段接入`,
|
||||
content:
|
||||
'本轮只实现前端工作台。生成结果会以候选形式展示,必须由用户确认才会写入草稿,不会自动覆盖当前内容。',
|
||||
'当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。',
|
||||
okText: '知道了',
|
||||
});
|
||||
const validSupplyUrl =
|
||||
@@ -1419,7 +1419,7 @@ const ProductEditPage = () => {
|
||||
onCompareCopy={(field) => setComparison({ variantId: null, field })}
|
||||
/>
|
||||
|
||||
<ProductVariantSection
|
||||
<ProductVariantSection
|
||||
form={form}
|
||||
snapshot={snapshot}
|
||||
collectionRecord={collectionRecord}
|
||||
@@ -1444,6 +1444,7 @@ const ProductEditPage = () => {
|
||||
skuCopyTaskId.current = null;
|
||||
}}
|
||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||
onImagesChanged={() => setDirty(true)}
|
||||
/>
|
||||
|
||||
<ProductOtherSection
|
||||
|
||||
@@ -205,6 +205,12 @@
|
||||
flex: 1;
|
||||
border-top: 1px dashed #d9d4df;
|
||||
}
|
||||
.sku-media-local-note {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #8d789c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-media-grid {
|
||||
display: grid;
|
||||
/* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -30,6 +30,9 @@ import type {
|
||||
} from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal';
|
||||
import { resolveSkuImageSelection } from './skuImageSelection';
|
||||
import { mediaApi, uploadProductImage } from '@/services/media';
|
||||
import './ProductVariantSection.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
@@ -131,6 +134,7 @@ type Props = {
|
||||
usesVariantTitle: boolean;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onImagesChanged: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -157,11 +161,43 @@ export function ProductVariantSection({
|
||||
usesVariantTitle,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
onImagesChanged,
|
||||
}: Props) {
|
||||
// 实时监听变体草稿,驱动已选图片和主图回显。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||
// 页面内保存已选预览,正式选图顺序同步到 Form 的 media_asset_ids。
|
||||
const [localImagesByVariant, setLocalImagesByVariant] = useState<Record<string, SkuEditorImage[]>>({});
|
||||
const [assetPreviewUrls, setAssetPreviewUrls] = useState<Record<string, string>>({});
|
||||
useEffect(() => setLocalImagesByVariant({}), [snapshot]);
|
||||
const [imageEditorTarget, setImageEditorTarget] = useState<{
|
||||
key: string;
|
||||
name: string;
|
||||
images: SkuEditorImage[];
|
||||
candidates: CollectionItemInspection['asset_transfers'];
|
||||
} | null>(null);
|
||||
const localObjectUrls = useRef<string[]>([]);
|
||||
useEffect(() => () => localObjectUrls.current.forEach((url) => URL.revokeObjectURL(url)), []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const refresh = async () => {
|
||||
const ids = [...new Set(snapshot.variants.flatMap((variant) =>
|
||||
variant.media.filter((media) => !media.source_url).map((media) => media.asset_id)
|
||||
))];
|
||||
const previews = await Promise.allSettled(ids.map((id) => mediaApi.createPreviewUrl(id)));
|
||||
if (!active) return;
|
||||
setAssetPreviewUrls(Object.fromEntries(previews.flatMap((result, index) =>
|
||||
result.status === 'fulfilled' ? [[ids[index], result.value.url]] : []
|
||||
)));
|
||||
};
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 8 * 60_000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [snapshot]);
|
||||
// SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。
|
||||
const skuInfoAspectDefinitions = activeAttributeSchema
|
||||
.filter(
|
||||
@@ -175,6 +211,7 @@ export function ProductVariantSection({
|
||||
definition.is_aspect && !definition.editor_field && isColorNameExpression(definition)
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Card className="editor-section sku-section" bordered={false}>
|
||||
<SectionTitle
|
||||
number="03"
|
||||
@@ -237,20 +274,25 @@ export function ProductVariantSection({
|
||||
: [];
|
||||
const selectedAssetIds =
|
||||
draftVariants[field.name]?.media_asset_ids ?? [];
|
||||
const selectedImages = selectedAssetIds
|
||||
.map((assetId) => ({
|
||||
assetId,
|
||||
url:
|
||||
imageCandidates.find(
|
||||
(candidate) => candidate.asset_id === assetId
|
||||
)?.source_url ??
|
||||
original?.media.find(
|
||||
(media) => media.asset_id === assetId
|
||||
)?.source_url,
|
||||
}))
|
||||
.filter((image): image is { assetId: string; url: string } =>
|
||||
Boolean(image.url)
|
||||
);
|
||||
const variantKey = sourceId ?? variantValue?.client_key ?? String(field.key);
|
||||
const savedImages = selectedAssetIds
|
||||
.map((assetId): SkuEditorImage | null => {
|
||||
const candidate = imageCandidates.find(
|
||||
(item) => item.asset_id === assetId
|
||||
);
|
||||
const url = candidate?.source_url ?? original?.media.find(
|
||||
(media) => media.asset_id === assetId
|
||||
)?.source_url ?? assetPreviewUrls[assetId];
|
||||
return url ? {
|
||||
id: candidate ? `collection:${candidate.id}` : `asset:${assetId}`,
|
||||
url,
|
||||
label: '已选 SKU 图片',
|
||||
origin: 'saved',
|
||||
assetId,
|
||||
} : null;
|
||||
})
|
||||
.filter((image): image is SkuEditorImage => image !== null);
|
||||
const selectedImages = localImagesByVariant[variantKey] ?? savedImages;
|
||||
const copyVariant = () =>
|
||||
add({
|
||||
...variantValue,
|
||||
@@ -425,11 +467,16 @@ export function ProductVariantSection({
|
||||
<Typography.Title level={5}>
|
||||
SKU 图片
|
||||
</Typography.Title>
|
||||
{Object.hasOwn(localImagesByVariant, variantKey) && (
|
||||
<Typography.Text className="sku-media-local-note">
|
||||
当前图片仅在本页预览,刷新后恢复
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className="sku-media-grid">
|
||||
{selectedImages.map((image, imageIndex) => (
|
||||
<div
|
||||
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
|
||||
key={image.assetId}
|
||||
key={image.id}
|
||||
>
|
||||
{imageIndex === 0 && (
|
||||
<span className="sku-media-main-badge">
|
||||
@@ -439,10 +486,15 @@ export function ProductVariantSection({
|
||||
<Image src={image.url} alt="已选 SKU 图片" />
|
||||
</div>
|
||||
))}
|
||||
{/* 图片选择交互将在后续接入。 */}
|
||||
<Button
|
||||
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`}
|
||||
type="dashed"
|
||||
onClick={() => setImageEditorTarget({
|
||||
key: variantKey,
|
||||
name: variantValue?.display_name || `SKU ${index + 1}`,
|
||||
images: selectedImages,
|
||||
candidates: imageCandidates,
|
||||
})}
|
||||
>
|
||||
选择图片
|
||||
</Button>
|
||||
@@ -703,5 +755,43 @@ export function ProductVariantSection({
|
||||
}}
|
||||
</Form.List>
|
||||
</Card>
|
||||
<SkuImageEditorModal
|
||||
open={!!imageEditorTarget}
|
||||
skuKey={imageEditorTarget?.key ?? ''}
|
||||
skuName={imageEditorTarget?.name ?? ''}
|
||||
initialImages={imageEditorTarget?.images ?? []}
|
||||
collectionImages={imageEditorTarget?.candidates ?? []}
|
||||
onCancel={() => setImageEditorTarget(null)}
|
||||
onConfirm={async (images) => {
|
||||
if (!imageEditorTarget) return;
|
||||
const assetIds = await resolveSkuImageSelection(images, collectionRecord?.id ?? null);
|
||||
const variantIndex = (form.getFieldValue('variants') as VariantValues[])
|
||||
.findIndex((variant) => (variant.id ?? variant.client_key) === imageEditorTarget.key);
|
||||
if (variantIndex < 0) throw new Error('SKU 已变化,请重新打开图片选择');
|
||||
form.setFieldValue(['variants', variantIndex, 'media_asset_ids'], assetIds);
|
||||
setLocalImagesByVariant((current) => ({
|
||||
...current,
|
||||
[imageEditorTarget.key]: images.map((image, index) => ({
|
||||
...image,
|
||||
assetId: assetIds[index],
|
||||
})),
|
||||
}));
|
||||
onImagesChanged();
|
||||
setImageEditorTarget(null);
|
||||
}}
|
||||
onUploadFile={async (file) => {
|
||||
const asset = await uploadProductImage(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
localObjectUrls.current.push(url);
|
||||
return {
|
||||
id: `asset:${asset.id}`,
|
||||
url,
|
||||
label: file.name,
|
||||
origin: 'upload',
|
||||
assetId: asset.id,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { DragEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
import { Button, Dropdown, Tooltip } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
DownOutlined,
|
||||
EyeOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export type SkuEditorImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string;
|
||||
origin: 'saved' | 'collection' | 'upload' | 'network';
|
||||
assetId?: string;
|
||||
transferId?: string;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
image: SkuEditorImage;
|
||||
mode: 'selected' | 'source';
|
||||
selected: boolean;
|
||||
cover?: boolean;
|
||||
menu: MenuProps;
|
||||
onToggle: () => void;
|
||||
onPreview: () => void;
|
||||
onRemove?: () => void;
|
||||
onDragStart?: () => void;
|
||||
onDragOver?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
/** 左右两列复用图片卡片,选中、预览和编辑操作保持一致。 */
|
||||
export function SkuImageCard({
|
||||
image,
|
||||
mode,
|
||||
selected,
|
||||
cover,
|
||||
menu,
|
||||
onToggle,
|
||||
onPreview,
|
||||
onRemove,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: Props) {
|
||||
const isSelectedColumn = mode === 'selected';
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
const stop = (event: MouseEvent<HTMLElement>) => event.stopPropagation();
|
||||
return (
|
||||
<div
|
||||
className={`${isSelectedColumn ? 'sku-image-selected-card' : 'sku-image-source-card'} ${cover ? 'is-cover' : ''} ${selected ? (isSelectedColumn ? 'is-marked' : 'is-selected') : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${isSelectedColumn ? '选择' : selected ? '移除' : '加入'}${image.label}`}
|
||||
aria-pressed={selected}
|
||||
draggable={isSelectedColumn}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={onToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<img src={image.url} alt={image.label} loading="lazy" draggable={false} />
|
||||
{cover && <span className="sku-image-cover-badge">主图</span>}
|
||||
{!isSelectedColumn && selected && (
|
||||
<Tooltip title="已加入">
|
||||
<CheckCircleOutlined className="sku-image-source-selected" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSelectedColumn && (
|
||||
<Tooltip title="从已选图片移出">
|
||||
<Button
|
||||
className="sku-image-remove"
|
||||
type="text"
|
||||
icon={<MinusCircleOutlined />}
|
||||
aria-label={`移出${image.label}`}
|
||||
onClick={(event) => {
|
||||
stop(event);
|
||||
onRemove?.();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="sku-image-card-actions" onKeyDown={(event) => event.stopPropagation()}>
|
||||
<Tooltip title="放大查看">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
aria-label={`放大查看${image.label}`}
|
||||
onClick={(event) => {
|
||||
stop(event);
|
||||
onPreview();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<Button type="text" onClick={stop}>
|
||||
编辑 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
.sku-image-editor-modal .ant-modal-container {
|
||||
display: flex;
|
||||
height: min(800px, calc(100dvh - 48px));
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-header {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 18px 26px;
|
||||
border-bottom: 1px solid #ebe9f1;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-title > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-title strong {
|
||||
color: #22212b;
|
||||
font-size: 20px;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-title span {
|
||||
overflow: hidden;
|
||||
color: #888396;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-close {
|
||||
top: 17px;
|
||||
right: 18px;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
padding: 0 24px 18px;
|
||||
}
|
||||
.sku-image-editor-modal .ant-modal-footer {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #ebe9f1;
|
||||
}
|
||||
.sku-image-editor-note {
|
||||
flex: 0 0 auto;
|
||||
margin: 16px 0 12px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid #e9e2ff;
|
||||
border-radius: 8px;
|
||||
background: #f8f5ff;
|
||||
color: #695d86;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-image-editor-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.sku-image-editor-left,
|
||||
.sku-image-editor-right {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sku-image-editor-left {
|
||||
padding-right: 24px;
|
||||
border-right: 1px solid #ece9f1;
|
||||
}
|
||||
.sku-image-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
}
|
||||
.sku-image-editor-toolbar .ant-btn:first-of-type {
|
||||
margin-right: auto;
|
||||
}
|
||||
.sku-image-editor-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin: 20px 0 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sku-image-editor-summary strong {
|
||||
color: #252332;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sku-image-editor-summary span {
|
||||
color: #8d879e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-image-editor-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: #d1ccd9 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-body-holder,
|
||||
.sku-image-editor-right .ant-tabs-body {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-body-holder {
|
||||
overflow: hidden;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-body {
|
||||
height: 100%;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: #d1ccd9 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.sku-image-selected-grid,
|
||||
.sku-image-source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
gap: 12px;
|
||||
padding: 3px;
|
||||
}
|
||||
.sku-image-selected-card,
|
||||
.sku-image-source-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
aspect-ratio: 3 / 4;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e8e5ed;
|
||||
border-radius: 9px;
|
||||
background: #f6f5f8;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sku-image-selected-card.is-cover {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.sku-image-selected-card.is-marked,
|
||||
.sku-image-source-card.is-selected {
|
||||
border-color: #7062e9;
|
||||
box-shadow: 0 0 0 2px rgb(112 98 233 / 12%);
|
||||
}
|
||||
.sku-image-selected-card > img,
|
||||
.sku-image-source-card > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.sku-image-cover-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
background: #7062e9;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sku-image-source-selected {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
background: rgb(255 255 255 / 94%);
|
||||
color: #7062e9;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sku-image-remove.ant-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
background: rgb(255 255 255 / 94%);
|
||||
color: #26232d;
|
||||
}
|
||||
.sku-image-remove.ant-btn:hover {
|
||||
background: rgb(255 255 255 / 94%) !important;
|
||||
color: #c34456 !important;
|
||||
}
|
||||
.sku-image-card-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 4px;
|
||||
background: rgb(36 34 44 / 68%);
|
||||
}
|
||||
.sku-image-card-actions .ant-btn {
|
||||
color: #fff;
|
||||
}
|
||||
.sku-image-card-actions .ant-btn:hover {
|
||||
color: #fff !important;
|
||||
background: rgb(255 255 255 / 14%) !important;
|
||||
}
|
||||
.sku-image-selected-card.is-cover .sku-image-card-actions {
|
||||
min-height: 42px;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-nav {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.sku-image-editor-right .ant-tabs-tab {
|
||||
padding: 9px 0;
|
||||
}
|
||||
.sku-image-collection-collapse {
|
||||
margin: 0 2px 12px 0;
|
||||
}
|
||||
.sku-image-collection-collapse .ant-collapse-content-box {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.sku-image-right-tab {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sku-image-network-controls,
|
||||
.sku-image-upload-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 0 2px 16px 0;
|
||||
}
|
||||
.sku-image-network-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sku-image-network-textarea.ant-input {
|
||||
height: 164px;
|
||||
line-height: 22px;
|
||||
resize: none;
|
||||
}
|
||||
.sku-image-upload-controls {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sku-image-upload-controls > span {
|
||||
color: #8d879e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-image-right-gallery {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: #d1ccd9 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.sku-image-editor-columns {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
}
|
||||
.sku-image-editor-left {
|
||||
padding-right: 0;
|
||||
padding-bottom: 22px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #ece9f1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.sku-image-editor-modal .ant-modal-body {
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
.sku-image-selected-grid,
|
||||
.sku-image-source-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.sku-image-editor-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { CollectionItemInspection } from '@atlas/contracts';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { SkuImageCard, type SkuEditorImage } from './SkuImageCard';
|
||||
import { SkuImageSourceTabs } from './SkuImageSourceTabs';
|
||||
import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary';
|
||||
import './SkuImageEditorModal.css';
|
||||
|
||||
type CollectionImage = CollectionItemInspection['asset_transfers'][number];
|
||||
|
||||
export type { SkuEditorImage } from './SkuImageCard';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
skuKey: string;
|
||||
skuName: string;
|
||||
initialImages: SkuEditorImage[];
|
||||
collectionImages: CollectionImage[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (images: SkuEditorImage[]) => Promise<void>;
|
||||
onUploadFile: (file: File) => Promise<SkuEditorImage>;
|
||||
};
|
||||
|
||||
const MAX_IMAGES = 30;
|
||||
|
||||
function downloadImage(image: SkuEditorImage) {
|
||||
const link = document.createElement('a');
|
||||
link.href = image.url;
|
||||
link.download = image.label || 'sku-image';
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
|
||||
return left.id === right.id || !!(left.assetId && left.assetId === right.assetId);
|
||||
}
|
||||
|
||||
/** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */
|
||||
export function SkuImageEditorModal({
|
||||
open,
|
||||
skuKey,
|
||||
skuName,
|
||||
initialImages,
|
||||
collectionImages,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
onUploadFile,
|
||||
}: Props) {
|
||||
const { message } = AntdApp.useApp();
|
||||
const [images, setImages] = useState<SkuEditorImage[]>(initialImages);
|
||||
const [markedIds, setMarkedIds] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const {
|
||||
networkUrls,
|
||||
setNetworkUrls,
|
||||
networkAdding,
|
||||
networkImages,
|
||||
uploadedImages,
|
||||
uploading,
|
||||
uploadFile,
|
||||
uploadToRight,
|
||||
addNetworkImages,
|
||||
} = useSkuImageLibrary(open, skuKey, images, onUploadFile);
|
||||
const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>(
|
||||
null
|
||||
);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const dragIndex = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setImages(initialImages);
|
||||
setMarkedIds([]);
|
||||
setActiveTab('collection');
|
||||
setPreview(null);
|
||||
}, [open, skuKey]);
|
||||
const addImage = (image: SkuEditorImage) => {
|
||||
const existing = images.find((item) => sameImage(item, image));
|
||||
if (existing) {
|
||||
setImages((current) => current.filter((item) => item.id !== existing.id));
|
||||
setMarkedIds((current) => current.filter((id) => id !== existing.id));
|
||||
return;
|
||||
}
|
||||
if (images.length >= MAX_IMAGES) {
|
||||
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
setImages((current) => [...current, image]);
|
||||
};
|
||||
const removeImages = (ids: string[]) => {
|
||||
setImages((current) => current.filter((image) => !ids.includes(image.id)));
|
||||
setMarkedIds((current) => current.filter((id) => !ids.includes(id)));
|
||||
};
|
||||
const setCover = (image: SkuEditorImage) => {
|
||||
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
|
||||
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
setImages((current) => {
|
||||
const selected = current.find((item) => sameImage(item, image)) ?? image;
|
||||
return [selected, ...current.filter((item) => !sameImage(item, image))];
|
||||
});
|
||||
};
|
||||
const markImage = (id: string) =>
|
||||
setMarkedIds((current) =>
|
||||
current.includes(id) ? current.filter((item) => item !== id) : [...current, id]
|
||||
);
|
||||
const pendingAction = (label: string) => message.info(`${label}将在后续接入图片处理服务`);
|
||||
const onBatchAction: MenuProps['onClick'] = ({ key }) => {
|
||||
if (!markedIds.length) {
|
||||
message.info('请先点击左侧图片进行选择');
|
||||
return;
|
||||
}
|
||||
if (key === 'delete') removeImages(markedIds);
|
||||
else if (key === 'download')
|
||||
images.filter((image) => markedIds.includes(image.id)).forEach(downloadImage);
|
||||
else pendingAction(String(key));
|
||||
};
|
||||
const batchMenu: MenuProps = {
|
||||
onClick: onBatchAction,
|
||||
items: [
|
||||
{ key: '添加水印', label: '添加水印' },
|
||||
{ key: '一键 3:4', label: '一键 3:4' },
|
||||
{ key: '批量翻译', label: '批量翻译' },
|
||||
{ key: 'download', label: '下载' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: '移出已选', danger: true },
|
||||
],
|
||||
};
|
||||
const imageMenu = (image: SkuEditorImage): MenuProps => ({
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (key === 'cover') setCover(image);
|
||||
else if (key === 'download') downloadImage(image);
|
||||
else pendingAction(String(key));
|
||||
},
|
||||
items: [
|
||||
{ key: 'AI 编辑', label: 'AI 编辑' },
|
||||
{ key: '添加水印', label: '添加水印' },
|
||||
{ key: '一键 3:4', label: '一键 3:4' },
|
||||
{ key: 'cover', label: '设为封图' },
|
||||
{ key: 'download', label: '下载' },
|
||||
],
|
||||
});
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
const available = MAX_IMAGES - images.length;
|
||||
const accepted = Array.from(files).filter((file) => {
|
||||
const error = uploadFileError(file);
|
||||
if (error) message.warning(error);
|
||||
return !error;
|
||||
});
|
||||
if (accepted.length > available) message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
|
||||
for (const file of accepted.slice(0, available)) {
|
||||
try {
|
||||
const image = await uploadFile(file);
|
||||
setImages((current) =>
|
||||
current.some((item) => item.assetId === image.assetId)
|
||||
? current
|
||||
: [...current, image]
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(`${file.name} 上传失败:${apiErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
if (fileInput.current) fileInput.current.value = '';
|
||||
};
|
||||
const sourceImages = (role: string): SkuEditorImage[] =>
|
||||
collectionImages
|
||||
.filter((image) => image.role === role)
|
||||
.map((image) => ({
|
||||
id: `collection:${image.id}`,
|
||||
url: image.source_url,
|
||||
label:
|
||||
image.role === 'source_main'
|
||||
? '采集主图'
|
||||
: image.role === 'source_sku'
|
||||
? '采集 SKU 图'
|
||||
: '采集详情图',
|
||||
origin: 'collection' as const,
|
||||
assetId: image.asset_id ?? undefined,
|
||||
transferId: image.id,
|
||||
}));
|
||||
const showPreview = (items: SkuEditorImage[], index: number) =>
|
||||
setPreview({ images: items, index });
|
||||
const renderSourceImages = (items: SkuEditorImage[]) =>
|
||||
items.length ? (
|
||||
<div className="sku-image-source-grid">
|
||||
{items.map((image, index) => {
|
||||
const selected = images.some((item) => sameImage(item, image));
|
||||
return (
|
||||
<SkuImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
mode="source"
|
||||
selected={selected}
|
||||
menu={imageMenu(image)}
|
||||
onToggle={() => addImage(image)}
|
||||
onPreview={() => showPreview(items, index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
|
||||
);
|
||||
|
||||
const confirmSelection = async () => {
|
||||
setConfirming(true);
|
||||
try {
|
||||
await onConfirm(images);
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
className="sku-image-editor-modal"
|
||||
open={open}
|
||||
title={
|
||||
<div>
|
||||
<strong>SKU 图片选择</strong>
|
||||
<span>{skuName}</span>
|
||||
</div>
|
||||
}
|
||||
width="min(1480px, 96vw)"
|
||||
onCancel={() => {
|
||||
if (!confirming) onCancel();
|
||||
}}
|
||||
closable={!confirming}
|
||||
maskClosable={!confirming}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel} disabled={confirming}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
loading={confirming}
|
||||
disabled={uploading}
|
||||
onClick={() => void confirmSelection()}
|
||||
>
|
||||
确认
|
||||
</Button>,
|
||||
]}
|
||||
centered
|
||||
>
|
||||
<div className="sku-image-editor-note">
|
||||
第一张为主图。确认后将选图写入当前编辑草稿,点击页面“保存”后持久化。
|
||||
</div>
|
||||
<div className="sku-image-editor-columns">
|
||||
<section className="sku-image-editor-left" aria-label="已选 SKU 图片">
|
||||
<div className="sku-image-editor-toolbar">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(event) => void uploadFiles(event.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
上传图片
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
images.length > 0 && markedIds.length === images.length ? (
|
||||
<CheckOutlined />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
setMarkedIds(
|
||||
markedIds.length === images.length
|
||||
? []
|
||||
: images.map((image) => image.id)
|
||||
)
|
||||
}
|
||||
disabled={!images.length}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Dropdown menu={batchMenu} trigger={['click']}>
|
||||
<Button>
|
||||
批量操作 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="sku-image-editor-summary">
|
||||
<strong>
|
||||
已选图片({images.length} / {MAX_IMAGES})
|
||||
</strong>
|
||||
<span>第一张为主图,拖动可调整顺序</span>
|
||||
</div>
|
||||
<div className="sku-image-editor-scroll">
|
||||
{images.length ? (
|
||||
<div className="sku-image-selected-grid">
|
||||
{images.map((image, index) => (
|
||||
<SkuImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
mode="selected"
|
||||
selected={markedIds.includes(image.id)}
|
||||
cover={index === 0}
|
||||
menu={imageMenu(image)}
|
||||
onToggle={() => markImage(image.id)}
|
||||
onPreview={() => showPreview(images, index)}
|
||||
onRemove={() => removeImages([image.id])}
|
||||
onDragStart={() => {
|
||||
dragIndex.current = index;
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const from = dragIndex.current;
|
||||
if (from === null || from === index) return;
|
||||
setImages((current) => {
|
||||
const next = [...current];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(index, 0, moved);
|
||||
return next;
|
||||
});
|
||||
dragIndex.current = null;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无已选图片,从右侧选择或上传"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="sku-image-editor-right" aria-label="可选图片">
|
||||
<SkuImageSourceTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
sourceImages={sourceImages}
|
||||
renderImages={renderSourceImages}
|
||||
networkImages={networkImages}
|
||||
networkUrls={networkUrls}
|
||||
onNetworkUrlsChange={setNetworkUrls}
|
||||
networkAdding={networkAdding}
|
||||
onAddNetworkImages={() => void addNetworkImages()}
|
||||
uploadedImages={uploadedImages}
|
||||
uploading={uploading}
|
||||
onUploadFile={uploadToRight}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
<Image.PreviewGroup
|
||||
items={preview?.images.map((image) => image.url) ?? []}
|
||||
preview={{
|
||||
open: !!preview,
|
||||
current: preview?.index ?? 0,
|
||||
onOpenChange: (visible) => {
|
||||
if (!visible) setPreview(null);
|
||||
},
|
||||
onChange: (current) =>
|
||||
setPreview((previous) =>
|
||||
previous ? { ...previous, index: current } : null
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, Collapse, Empty, Input, Tabs, Upload } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { SkuEditorImage } from './SkuImageCard';
|
||||
|
||||
const NETWORK_PLACEHOLDER = `请输入图片链接,每行一个(仅支持 JPG、PNG 格式):
|
||||
https://example.com/image1.jpg
|
||||
https://example.com/image2.png
|
||||
|
||||
• 请确保图片链接可以正常访问
|
||||
• 建议图片大小不超过 5MB
|
||||
• 自动过滤重复和无效链接`;
|
||||
|
||||
type Props = {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
sourceImages: (role: string) => SkuEditorImage[];
|
||||
renderImages: (images: SkuEditorImage[]) => ReactNode;
|
||||
networkImages: SkuEditorImage[];
|
||||
networkUrls: string;
|
||||
onNetworkUrlsChange: (value: string) => void;
|
||||
networkAdding: boolean;
|
||||
onAddNetworkImages: () => void;
|
||||
uploadedImages: SkuEditorImage[];
|
||||
uploading: boolean;
|
||||
onUploadFile: (file: File) => Promise<false | typeof Upload.LIST_IGNORE>;
|
||||
};
|
||||
|
||||
/** 图片来源分区只负责展示和输入,选中及素材状态由弹窗控制。 */
|
||||
export function SkuImageSourceTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
sourceImages,
|
||||
renderImages,
|
||||
networkImages,
|
||||
networkUrls,
|
||||
onNetworkUrlsChange,
|
||||
networkAdding,
|
||||
onAddNetworkImages,
|
||||
uploadedImages,
|
||||
uploading,
|
||||
onUploadFile,
|
||||
}: Props) {
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={onTabChange}
|
||||
items={[
|
||||
{
|
||||
key: 'ai',
|
||||
label: 'AI 生图记录',
|
||||
children: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无 AI 生图记录"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'collection',
|
||||
label: '采集图片',
|
||||
children: (
|
||||
<Collapse
|
||||
className="sku-image-collection-collapse"
|
||||
defaultActiveKey={['source_main', 'source_sku', 'source_detail']}
|
||||
items={[
|
||||
{
|
||||
key: 'source_main',
|
||||
label: '主图',
|
||||
children: renderImages(sourceImages('source_main')),
|
||||
},
|
||||
{
|
||||
key: 'source_sku',
|
||||
label: 'SKU 图',
|
||||
children: renderImages(sourceImages('source_sku')),
|
||||
},
|
||||
{
|
||||
key: 'source_detail',
|
||||
label: '详情图',
|
||||
children: renderImages(sourceImages('source_detail')),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'network',
|
||||
label: '网络图片',
|
||||
children: (
|
||||
<div className="sku-image-right-tab">
|
||||
<div className="sku-image-network-controls">
|
||||
<Input.TextArea
|
||||
className="sku-image-network-textarea"
|
||||
rows={7}
|
||||
placeholder={NETWORK_PLACEHOLDER}
|
||||
value={networkUrls}
|
||||
onChange={(event) => onNetworkUrlsChange(event.target.value)}
|
||||
aria-label="网络图片链接,每行一个"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={networkAdding}
|
||||
disabled={!networkUrls.trim()}
|
||||
onClick={onAddNetworkImages}
|
||||
>
|
||||
添加图片链接
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sku-image-right-gallery">
|
||||
{renderImages(networkImages)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
label: '上传图片',
|
||||
children: (
|
||||
<div className="sku-image-right-tab">
|
||||
<div className="sku-image-upload-controls">
|
||||
<Upload
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={onUploadFile}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
>
|
||||
上传图片
|
||||
</Button>
|
||||
</Upload>
|
||||
<span>图片先放在右侧,点击图片后加入左侧已选区</span>
|
||||
</div>
|
||||
<div className="sku-image-right-gallery">
|
||||
{renderImages(uploadedImages)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CollectionItemInspection } from '@atlas/contracts';
|
||||
import { collectionApi } from '@/services/collection';
|
||||
import type { SkuEditorImage } from './SkuImageCard';
|
||||
|
||||
const wait = (milliseconds: number) =>
|
||||
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
|
||||
/** 确认时转存已选采集图;草稿只接受已校验的正式素材 ID。 */
|
||||
export async function resolveSkuImageSelection(
|
||||
images: SkuEditorImage[],
|
||||
collectionItemId: string | null
|
||||
): Promise<string[]> {
|
||||
const transferIds = [
|
||||
...new Set(
|
||||
images
|
||||
.filter((image) => !image.assetId && image.transferId)
|
||||
.map((image) => image.transferId!)
|
||||
),
|
||||
];
|
||||
const resolved = new Map<string, string>();
|
||||
if (transferIds.length) {
|
||||
if (!collectionItemId) throw new Error('缺少采集记录,无法转存所选图片');
|
||||
const current = await collectionApi.inspection(collectionItemId);
|
||||
const pending = transferIds.filter(
|
||||
(id) =>
|
||||
current.asset_transfers.find((item) => item.id === id)?.status ===
|
||||
'pending_selection'
|
||||
);
|
||||
if (pending.length) {
|
||||
await collectionApi.materializeAssets(collectionItemId, { transfer_ids: pending });
|
||||
}
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
const inspection = await collectionApi.inspection(collectionItemId);
|
||||
for (const id of transferIds) {
|
||||
const transfer = inspection.asset_transfers.find((item) => item.id === id);
|
||||
if (transfer?.status === 'failed') {
|
||||
throw new Error('采集图片转存失败,请稍后重试');
|
||||
}
|
||||
if (transfer?.status === 'succeeded' && transfer.asset_id) {
|
||||
resolved.set(id, transfer.asset_id);
|
||||
}
|
||||
}
|
||||
if (resolved.size === transferIds.length) break;
|
||||
await wait(1_000);
|
||||
}
|
||||
if (resolved.size !== transferIds.length) {
|
||||
throw new Error('采集图片仍在转存,请稍后重新确认');
|
||||
}
|
||||
}
|
||||
const assetIds = images.map(
|
||||
(image) => image.assetId ?? (image.transferId ? resolved.get(image.transferId) : undefined)
|
||||
);
|
||||
if (assetIds.some((id) => !id)) throw new Error('有图片尚未转存,无法写入草稿');
|
||||
if (new Set(assetIds).size !== assetIds.length) {
|
||||
throw new Error('所选图片包含重复素材,请移除重复项');
|
||||
}
|
||||
return assetIds as string[];
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { App as AntdApp, Upload } from 'antd';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { mediaApi } from '@/services/media';
|
||||
import type { SkuEditorImage } from './SkuImageCard';
|
||||
|
||||
export function uploadFileError(file: File) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
return `${file.name} 不是 JPG、PNG 或 WebP 图片`;
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) return `${file.name} 超过 20 MB`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function networkImageUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
|
||||
if (!/\.(?:jpe?g|png)$/i.test(url.pathname)) return null;
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 工作空间候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */
|
||||
export function useSkuImageLibrary(
|
||||
open: boolean,
|
||||
skuKey: string,
|
||||
selectedImages: SkuEditorImage[],
|
||||
onUploadFile: (file: File) => Promise<SkuEditorImage>
|
||||
) {
|
||||
const { message } = AntdApp.useApp();
|
||||
const [networkUrls, setNetworkUrls] = useState('');
|
||||
const [networkAdding, setNetworkAdding] = useState(false);
|
||||
const [networkImages, setNetworkImages] = useState<SkuEditorImage[]>([]);
|
||||
const [uploadedImages, setUploadedImages] = useState<SkuEditorImage[]>([]);
|
||||
const [uploadCount, setUploadCount] = useState(0);
|
||||
const fingerprints = useRef(new Set<string>());
|
||||
const networkRequest = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
networkRequest.current += 1;
|
||||
setNetworkUrls('');
|
||||
setNetworkAdding(false);
|
||||
}, [open, skuKey]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
const refresh = () =>
|
||||
void mediaApi
|
||||
.listProductUploads()
|
||||
.then((uploads) => {
|
||||
if (!active) return;
|
||||
const loaded = uploads.map((asset) => ({
|
||||
id: `asset:${asset.id}`,
|
||||
url: asset.preview_url,
|
||||
label: '上传图片',
|
||||
origin: 'upload' as const,
|
||||
assetId: asset.id,
|
||||
}));
|
||||
setUploadedImages((current) => [
|
||||
...current.filter(
|
||||
(image) => !loaded.some((item) => item.assetId === image.assetId)
|
||||
),
|
||||
...loaded,
|
||||
]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`);
|
||||
});
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 8 * 60_000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [open, message]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
const refresh = () =>
|
||||
void mediaApi
|
||||
.listProductImports()
|
||||
.then((imports) => {
|
||||
if (!active) return;
|
||||
const loaded = imports.map((asset) => ({
|
||||
id: `asset:${asset.id}`,
|
||||
url: asset.preview_url,
|
||||
label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片',
|
||||
origin: 'network' as const,
|
||||
assetId: asset.id,
|
||||
sourceUrl: asset.source_url,
|
||||
}));
|
||||
setNetworkImages((current) => [
|
||||
...current.filter(
|
||||
(image) => !loaded.some((item) => item.assetId === image.assetId)
|
||||
),
|
||||
...loaded,
|
||||
]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`);
|
||||
});
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 8 * 60_000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [open, message]);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploadCount((count) => count + 1);
|
||||
try {
|
||||
return await onUploadFile(file);
|
||||
} finally {
|
||||
setUploadCount((count) => count - 1);
|
||||
}
|
||||
};
|
||||
const uploadToRight = async (file: File) => {
|
||||
const error = uploadFileError(file);
|
||||
if (error) {
|
||||
message.warning(error);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
const fingerprint = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (fingerprints.current.has(fingerprint)) {
|
||||
message.info(`${file.name} 已在上传图片列表中`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
fingerprints.current.add(fingerprint);
|
||||
try {
|
||||
const image = await uploadFile(file);
|
||||
setUploadedImages((current) =>
|
||||
current.some((item) => item.assetId === image.assetId)
|
||||
? current
|
||||
: [image, ...current]
|
||||
);
|
||||
} catch (uploadError) {
|
||||
fingerprints.current.delete(fingerprint);
|
||||
message.error(`${file.name} 上传失败:${apiErrorMessage(uploadError)}`);
|
||||
}
|
||||
return Upload.LIST_IGNORE;
|
||||
};
|
||||
const addNetworkImages = async () => {
|
||||
if (!networkUrls.trim() || networkAdding) return;
|
||||
const request = ++networkRequest.current;
|
||||
const seen = new Set([
|
||||
...networkImages.map((image) => image.sourceUrl ?? image.url),
|
||||
...selectedImages.map((image) => image.sourceUrl ?? image.url),
|
||||
]);
|
||||
const candidates: string[] = [];
|
||||
let skipped = 0;
|
||||
for (const line of networkUrls
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)) {
|
||||
const url = networkImageUrl(line);
|
||||
if (!url || seen.has(url)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
candidates.push(url);
|
||||
}
|
||||
if (!candidates.length) {
|
||||
message.warning('没有可添加的 JPG 或 PNG 图片链接');
|
||||
return;
|
||||
}
|
||||
setNetworkAdding(true);
|
||||
const results = await Promise.allSettled(
|
||||
candidates.map((url) => mediaApi.importProductImage({ url }))
|
||||
);
|
||||
if (request !== networkRequest.current) return;
|
||||
const valid = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
);
|
||||
skipped += results.length - valid.length;
|
||||
setNetworkImages((current) => {
|
||||
const added = valid.map((asset) => ({
|
||||
id: `asset:${asset.id}`,
|
||||
url: asset.preview_url,
|
||||
label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片',
|
||||
origin: 'network' as const,
|
||||
assetId: asset.id,
|
||||
sourceUrl: asset.source_url,
|
||||
}));
|
||||
return [
|
||||
...added,
|
||||
...current.filter((image) => !added.some((item) => item.id === image.id)),
|
||||
];
|
||||
});
|
||||
setNetworkUrls('');
|
||||
setNetworkAdding(false);
|
||||
if (valid.length) {
|
||||
message.success(
|
||||
`已导入 ${valid.length} 张网络图片${skipped ? `,过滤 ${skipped} 条重复或无效链接` : ''}`
|
||||
);
|
||||
} else message.warning(`未找到可访问的图片链接,已过滤 ${skipped} 条`);
|
||||
};
|
||||
|
||||
return {
|
||||
networkUrls,
|
||||
setNetworkUrls,
|
||||
networkAdding,
|
||||
networkImages,
|
||||
uploadedImages,
|
||||
uploading: uploadCount > 0,
|
||||
uploadFile,
|
||||
uploadToRight,
|
||||
addNetworkImages,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import type {
|
||||
MediaAccessUrl,
|
||||
MediaAsset,
|
||||
MediaUploadIntent,
|
||||
ProductUploadImage,
|
||||
ProductImportedImage,
|
||||
ImportProductImageRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
@@ -12,6 +15,12 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||
export const mediaApi = {
|
||||
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
|
||||
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
|
||||
importProductImage: (payload: ImportProductImageRequest) =>
|
||||
api.post<ProductImportedImage>('/media-assets/product-imports', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
createUploadIntent: (payload: CreateMediaUploadIntentRequest) =>
|
||||
api.post<MediaUploadIntent>('/media/upload-intents', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
@@ -47,9 +56,21 @@ export async function sha256Hex(file: File): Promise<string> {
|
||||
* 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。
|
||||
*/
|
||||
export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
|
||||
return uploadImage(file, 'watermark_image');
|
||||
}
|
||||
|
||||
/** 商品图片沿用素材直传协议,完成服务端校验后才可写入 SKU 草稿。 */
|
||||
export async function uploadProductImage(file: File): Promise<MediaAsset> {
|
||||
return uploadImage(file, 'product_image');
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
file: File,
|
||||
purpose: CreateMediaUploadIntentRequest['purpose']
|
||||
): Promise<MediaAsset> {
|
||||
const checksumSha256 = await sha256Hex(file);
|
||||
const intent = await mediaApi.createUploadIntent({
|
||||
purpose: 'watermark_image',
|
||||
purpose,
|
||||
filename: file.name,
|
||||
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
|
||||
byte_size: file.size,
|
||||
|
||||
Reference in New Issue
Block a user