feat(ai-image): AI生图开发

This commit is contained in:
2026-09-27 22:40:52 +08:00
parent e670d06ca6
commit a29dd92676
105 changed files with 9943 additions and 361 deletions
+46 -27
View File
@@ -94,36 +94,12 @@
margin-top: 16px;
}
/* 3x3 水印位置九宫格 */
.position-grid {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-auto-rows: 44px;
gap: 8px;
}
.position-grid-cell {
border: 1px solid var(--border-color);
border-radius: 6px;
background: #f5f5f5;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.position-grid-cell:hover {
border-color: var(--primary-light);
}
.position-grid-cell.active {
border-color: var(--primary-color);
background: rgba(139, 92, 246, 0.12);
}
/* 水印预览:模拟透明底图的棋盘格 */
.watermark-preview {
display: flex;
padding: 12px;
position: relative;
max-width: 100%;
padding: 5px;
box-sizing: border-box;
border: 1px dashed var(--border-color);
border-radius: 4px;
@@ -132,6 +108,49 @@
overflow: hidden;
}
.watermark-preview-mark {
display: inline-flex;
position: absolute;
align-items: center;
justify-content: center;
max-width: calc(100% - 10px);
max-height: calc(100% - 10px);
overflow: hidden;
}
.watermark-preview-mark-draggable {
cursor: grab;
touch-action: none;
user-select: none;
}
.watermark-preview-mark-dragging {
cursor: grabbing;
z-index: 1;
}
.watermark-preview-dialog-content {
display: flex;
justify-content: center;
}
.watermark-preview-dialog-footer {
display: flex;
justify-content: center;
}
.watermark-editor-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
}
@media (max-width: 575px) {
.watermark-editor-pair {
grid-template-columns: minmax(0, 1fr);
}
}
.watermark-form-actions {
display: flex;
justify-content: center;
+150 -48
View File
@@ -1,10 +1,10 @@
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Typography } from 'antd';
import type { WatermarkPosition } from '@atlas/contracts';
import type { WatermarkTemplateConfig } from '@atlas/contracts';
/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */
/** 旧版九宫格位置与精确坐标的映射,供已有模板读取。 */
export const WATERMARK_POSITION_OPTIONS: Array<{
value: WatermarkPosition;
label: string;
@@ -25,71 +25,173 @@ export const WATERMARK_POSITION_OPTIONS: Array<{
export const watermarkPositionLabel = (position: string): string =>
WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position;
const POSITION_FLEX: Record<
WatermarkPosition,
Pick<CSSProperties, 'justifyContent' | 'alignItems'>
> = {
top_left: { justifyContent: 'flex-start', alignItems: 'flex-start' },
top_center: { justifyContent: 'center', alignItems: 'flex-start' },
top_right: { justifyContent: 'flex-end', alignItems: 'flex-start' },
middle_left: { justifyContent: 'flex-start', alignItems: 'center' },
center: { justifyContent: 'center', alignItems: 'center' },
middle_right: { justifyContent: 'flex-end', alignItems: 'center' },
bottom_left: { justifyContent: 'flex-start', alignItems: 'flex-end' },
bottom_center: { justifyContent: 'center', alignItems: 'flex-end' },
bottom_right: { justifyContent: 'flex-end', alignItems: 'flex-end' },
};
interface WatermarkPreviewProps {
/** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */
config: Partial<WatermarkTemplateConfig>;
width?: number;
height?: number;
onPositionChange?: (position: { position_x: number; position_y: number }) => void;
}
type DragState = {
pointerId: number;
offsetX: number;
offsetY: number;
markWidth: number;
markHeight: number;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), Math.max(min, max));
const roundCoordinate = (value: number) => Math.round(clamp(value, 0, 1) * 10_000) / 10_000;
const PREVIEW_INSET_PX = 5;
// 开发服务直接加载前端源码,范围须与共享契约中的校验值保持一致。
export const WATERMARK_IMAGE_SIZE_MIN_PX = 10;
export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80;
export const WATERMARK_IMAGE_SIZE_MAX_PX = 200;
export const WATERMARK_BORDER_RADIUS_MAX_PX = 100;
/**
* 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1),
* 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。
*/
const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => {
const [imageBroken, setImageBroken] = useState(false);
const position = config.position ?? 'bottom_right';
const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right;
const WatermarkPreview = ({ config, width = 240, height = 320, onPositionChange }: WatermarkPreviewProps) => {
const [brokenImageUrl, setBrokenImageUrl] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<{ url: string; width: number; height: number } | null>(null);
const [dragPoint, setDragPoint] = useState<{ left: number; top: number } | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const legacy = WATERMARK_POSITION_OPTIONS.find((option) => option.value === config.position);
const positionX = config.position_x ?? (legacy ? legacy.col / 2 : 1);
const positionY = config.position_y ?? (legacy ? legacy.row / 2 : 1);
const previewUrl = config.image_asset?.preview_url;
const legacyImageSize = Number(config.scale_ratio) * width * 0.6;
const imageSize = config.image_size_px
?? (Number.isFinite(legacyImageSize)
? Math.round(legacyImageSize) : WATERMARK_IMAGE_SIZE_DEFAULT_PX);
const imageAspect = imageDimensions && imageDimensions.url === previewUrl && imageDimensions.height > 0
? imageDimensions.width / imageDimensions.height : 1;
const imageWidth = imageAspect >= 1 ? imageSize : Math.max(1, Math.round(imageSize * imageAspect));
const imageHeight = imageAspect >= 1 ? Math.max(1, Math.round(imageSize / imageAspect)) : imageSize;
const moveMark = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
const preview = previewRef.current;
if (!drag || !preview || drag.pointerId !== event.pointerId) return null;
const bounds = preview.getBoundingClientRect();
const point = {
left: clamp(event.clientX - bounds.left - drag.offsetX, PREVIEW_INSET_PX, bounds.width - drag.markWidth - PREVIEW_INSET_PX),
top: clamp(event.clientY - bounds.top - drag.offsetY, PREVIEW_INSET_PX, bounds.height - drag.markHeight - PREVIEW_INSET_PX),
};
setDragPoint(point);
return { point, bounds, drag };
};
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!onPositionChange || !previewRef.current) return;
const markBounds = event.currentTarget.getBoundingClientRect();
const previewBounds = previewRef.current.getBoundingClientRect();
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - markBounds.left,
offsetY: event.clientY - markBounds.top,
markWidth: markBounds.width,
markHeight: markBounds.height,
};
setDragPoint({ left: markBounds.left - previewBounds.left, top: markBounds.top - previewBounds.top });
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const result = moveMark(event);
if (!result) return;
const { point, bounds, drag } = result;
dragRef.current = null;
setDragPoint(null);
event.currentTarget.releasePointerCapture(event.pointerId);
// 坐标按水印实际可移动范围归一化,松手后保留精确位置。
const travelX = bounds.width - drag.markWidth - PREVIEW_INSET_PX * 2;
const travelY = bounds.height - drag.markHeight - PREVIEW_INSET_PX * 2;
onPositionChange?.({
position_x: roundCoordinate(travelX > 0 ? (point.left - PREVIEW_INSET_PX) / travelX : 0),
position_y: roundCoordinate(travelY > 0 ? (point.top - PREVIEW_INSET_PX) / travelY : 0),
});
};
const moveWithKeyboard = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!onPositionChange) return;
let x = positionX;
let y = positionY;
if (event.key === 'ArrowUp') y -= 0.01;
else if (event.key === 'ArrowDown') y += 0.01;
else if (event.key === 'ArrowLeft') x -= 0.01;
else if (event.key === 'ArrowRight') x += 0.01;
else return;
event.preventDefault();
onPositionChange({ position_x: roundCoordinate(x), position_y: roundCoordinate(y) });
};
return (
<div
ref={previewRef}
className="watermark-preview"
style={{ width, height, ...flex, opacity: Number(config.opacity ?? '1') }}
style={{ width, height: 'auto', aspectRatio: `${width} / ${height}` }}
>
{config.image_asset ? (
imageBroken || !previewUrl ? (
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
<div
className={`watermark-preview-mark${config.image_asset ? ' watermark-preview-mark-image' : ''}${onPositionChange ? ' watermark-preview-mark-draggable' : ''}${dragPoint ? ' watermark-preview-mark-dragging' : ''}`}
style={{
opacity: Number(config.opacity ?? '0.26'),
left: dragPoint?.left ?? `calc(${positionX * 100}% + ${PREVIEW_INSET_PX - positionX * PREVIEW_INSET_PX * 2}px)`,
top: dragPoint?.top ?? `calc(${positionY * 100}% + ${PREVIEW_INSET_PX - positionY * PREVIEW_INSET_PX * 2}px)`,
transform: dragPoint ? 'none' : `translate(-${positionX * 100}%, -${positionY * 100}%)`,
}}
role={onPositionChange ? 'button' : undefined}
tabIndex={onPositionChange ? 0 : undefined}
aria-label={onPositionChange ? `拖动水印调整位置,水平 ${Math.round(positionX * 1000) / 10}%,垂直 ${Math.round(positionY * 1000) / 10}%;可用方向键微调` : undefined}
onPointerDown={startDrag}
onPointerMove={moveMark}
onPointerUp={finishDrag}
onPointerCancel={() => { dragRef.current = null; setDragPoint(null); }}
onKeyDown={moveWithKeyboard}
>
{config.image_asset ? (
brokenImageUrl === previewUrl || !previewUrl ? (
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
) : (
<img
src={previewUrl}
alt="水印图片"
draggable={false}
style={{
width: imageWidth,
height: imageHeight,
borderRadius: config.border_radius_px ?? 0,
objectFit: 'contain',
}}
onLoad={(event) => setImageDimensions({
url: previewUrl,
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
})}
onError={() => setBrokenImageUrl(previewUrl)}
/>
)
) : (
<img
src={previewUrl}
alt="水印图片"
<span
style={{
// scale_ratio 以底图短边为基准的视觉近似:1.0 ≈ 底图宽度的 60%
width: Math.round(Number(config.scale_ratio ?? '1') * width * 0.6),
maxWidth: '80%',
fontSize: config.font_size_px ?? 15,
fontWeight: 600,
color: '#111827',
whiteSpace: 'nowrap',
textShadow: '0 0 2px rgba(255,255,255,0.8)',
}}
onError={() => setImageBroken(true)}
/>
)
) : (
<span
style={{
fontSize: config.font_size_px ?? 24,
fontWeight: 600,
color: '#111827',
whiteSpace: 'nowrap',
textShadow: '0 0 2px rgba(255,255,255,0.8)',
}}
>
{config.text_content || '文字水印'}
</span>
)}
>
{config.text_content || '文字水印'}
</span>
)}
</div>
</div>
);
};
@@ -0,0 +1,15 @@
.collection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px;
}
.collection-toolbar-usage {
width: 240px;
max-width: 100%;
margin-left: auto;
text-align: right;
}
@@ -8,6 +8,7 @@ import { collectionApi } from '@/services/collection';
import { apiErrorMessage } from '@/services/api';
import { usePlatform } from '@/services/platform';
import { productEditorApi } from '@/services/productEditor';
import './CollectionPage.css';
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
export default function CollectionPage() {
@@ -21,8 +22,10 @@ export default function CollectionPage() {
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => void openEditor(item.id)}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
];
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} />
<Space wrap style={{ marginBottom: 16 }}><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /><Select allowClear placeholder="采集来源" value={source} onChange={setSource} style={{ width: 140 }} options={[{ value: 'OZON', label: 'Ozon' }, { value: '1688', label: '1688' }, { value: 'TAOBAO', label: '淘宝' }, { value: 'TMALL', label: '天猫' }]} /><Button type="primary" onClick={() => void load()}>查询</Button></Space>
<div className="collection-toolbar">
<Space wrap><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /><Select allowClear placeholder="采集来源" value={source} onChange={setSource} style={{ width: 140 }} options={[{ value: 'OZON', label: 'Ozon' }, { value: '1688', label: '1688' }, { value: 'TAOBAO', label: '淘宝' }, { value: 'TMALL', label: '天猫' }]} /><Button type="primary" onClick={() => void load()}>查询</Button></Space>
<Progress className="collection-toolbar-usage" percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} />
</div>
<Table rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => setSelected(keys.map(String)) }} locale={{ emptyText: '暂无采集商品,点击右上角手动创建商品' }} />
<Modal title="手动创建商品" open={createOpen} footer={null} onCancel={() => setCreateOpen(false)} destroyOnHidden><Form form={form} layout="vertical" onFinish={create}><Form.Item name="title" label="商品名称"><Input maxLength={300} placeholder="可稍后在商品编辑中完善" /></Form.Item><Form.Item name="note" label="备注"><Input.TextArea maxLength={2000} /></Form.Item><Space><Button type="primary" htmlType="submit">创建草稿</Button><Button onClick={() => setCreateOpen(false)}>取消</Button></Space></Form></Modal>
</Card>;
@@ -0,0 +1,123 @@
.online-products-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.online-products-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.online-products-heading h3 {
margin: 0 0 4px !important;
}
.online-products-store-select {
min-width: 190px;
max-width: 260px;
}
.online-products-sync-status {
background: #fff;
}
.online-products-filters {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.online-products-search {
width: min(320px, 100%);
}
.online-products-filter {
min-width: 145px;
}
.online-products-list {
overflow: hidden;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.online-products-list .ant-table-wrapper {
overflow: hidden;
}
.online-products-list-footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 53px;
padding: 8px 16px;
border-top: 1px solid var(--border-color);
}
.online-product-name-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.online-product-image {
display: flex;
flex: 0 0 44px;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
overflow: hidden;
border-radius: 6px;
background: #f3f4f6;
color: var(--text-secondary);
font-size: 11px;
}
.online-product-image img {
object-fit: cover;
}
.online-product-name-text,
.online-product-ids {
display: flex;
flex-direction: column;
min-width: 0;
}
.online-product-name-text .ant-typography {
display: block;
max-width: 200px;
}
.online-product-name-text .ant-typography + .ant-typography,
.online-product-ids .ant-typography + .ant-typography {
font-size: 12px;
}
.online-product-detail {
width: 100%;
}
.online-product-detail-heading {
display: flex;
align-items: center;
gap: 14px;
}
.online-product-detail-heading h5 {
margin: 0 0 4px !important;
}
.online-product-detail-empty-image {
display: grid;
place-items: center;
flex: 0 0 64px;
height: 64px;
border-radius: 6px;
background: #f3f4f6;
color: var(--text-secondary);
}
@media (max-width: 760px) {
.online-products-heading {
flex-direction: column;
}
.online-products-heading > .ant-space {
width: 100%;
}
.online-products-store-select {
flex: 1;
}
.online-products-filters > * {
flex: 1 1 145px;
}
.online-products-search {
flex-basis: 100%;
width: 100%;
}
}
@@ -1,11 +1,426 @@
import PagePlaceholder from '@/components/PagePlaceholder';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { App as AntdApp, Alert, Button, Card, Select, Space, Typography } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import type {
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
Store,
} from '@atlas/contracts';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { onlineProductsApi, type OnlineProductFilters } from '@/services/onlineProducts';
import { usePlatform } from '@/services/platform';
import { storesApi } from '@/services/stores';
import { OnlineProductDetailDrawer } from './components/OnlineProductDetailDrawer';
import { OnlineProductFilters as Filters } from './components/OnlineProductFilters';
import { OnlineProductTable } from './components/OnlineProductTable';
import { OnlineSyncStatus } from './components/OnlineSyncStatus';
import './OnlineProductsPage.css';
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
const OnlineProductsPage = () => (
<PagePlaceholder
title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
/>
);
const initialFilters: OnlineProductFilters = { q: '', visibility: 'all' };
const isActive = (run: OnlineProductSyncRun | null) =>
run?.status === 'queued' || run?.status === 'running';
export default OnlineProductsPage;
export default function OnlineProductsPage() {
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const {
currentPlatform,
context,
contextLoading,
error: platformError,
reload: reloadPlatforms,
} = usePlatform();
const platformId = currentPlatform?.id ?? null;
const [stores, setStores] = useState<Store[]>([]);
const [storesLoadedPlatformId, setStoresLoadedPlatformId] = useState<string | null>(null);
const [storesLoading, setStoresLoading] = useState(false);
const [storesError, setStoresError] = useState<string | null>(null);
const [storesRetry, setStoresRetry] = useState(0);
const [selectedStore, setSelectedStore] = useState<{ platformId: string; id: string } | null>(null);
const [filters, setFilters] = useState<OnlineProductFilters>(initialFilters);
const [items, setItems] = useState<OnlineProduct[]>([]);
const [meta, setMeta] = useState<OnlineProductsMeta | null>(null);
const [loadedScope, setLoadedScope] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const [refreshVersion, setRefreshVersion] = useState(0);
const [latestRun, setLatestRun] = useState<OnlineProductSyncRun | null>(null);
const [runScope, setRunScope] = useState<string | null>(null);
const [history, setHistory] = useState<OnlineProductSyncRun[]>([]);
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [syncStarting, setSyncStarting] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<OnlineProduct | null>(null);
const [detailScope, setDetailScope] = useState<string | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const listRequestRef = useRef(0);
const detailRequestRef = useRef(0);
const scopeRef = useRef<string | null>(null);
const contextStoreId =
context?.platform.id === platformId ? (context.default_store?.id ?? null) : null;
const scopedStores = useMemo(() => stores.filter((store) => store.platform_id === platformId), [stores, platformId]);
const storeId =
(selectedStore?.platformId === platformId ? selectedStore.id : null) ??
contextStoreId ?? scopedStores.find((store) => store.is_default)?.id ?? null;
const store = useMemo(
() => scopedStores.find((item) => item.id === storeId) ?? null,
[scopedStores, storeId]
);
const scopeKey = platformId && storeId ? `${platformId}:${storeId}` : null;
scopeRef.current = scopeKey;
const visibleItems = loadedScope === scopeKey ? items : [];
const visibleMeta = loadedScope === scopeKey ? meta : null;
const visibleListError = loadedScope === scopeKey ? listError : null;
const visibleRun = runScope === scopeKey ? latestRun : null;
const visibleHistory = runScope === scopeKey ? history : [];
const visibleHistoryError = runScope === scopeKey ? historyError : null;
const visibleHistoryCursor = runScope === scopeKey ? historyCursor : null;
const canSync =
currentPlatform?.code.toUpperCase() === 'OZON' &&
store?.status === 'active' &&
store.credential_status === 'valid';
useEffect(() => {
setSelectedStore(null);
setStores([]);
setStoresLoadedPlatformId(null);
setFilters(initialFilters);
setItems([]);
setMeta(null);
setLatestRun(null);
setHistory([]);
setHistoryError(null);
setHistoryOpen(false);
detailRequestRef.current += 1;
setDetailOpen(false);
if (!platformId) return;
let cancelled = false;
setStoresLoading(true);
setStoresError(null);
void (async () => {
try {
const all: Store[] = [];
let cursor: string | null = null;
do {
const result = await storesApi.list(platformId, cursor ?? undefined);
all.push(...result.data);
cursor = result.meta.next_cursor;
} while (cursor);
if (!cancelled) setStores(all);
} catch (error) {
if (!cancelled) setStoresError(apiErrorMessage(error));
} finally {
if (!cancelled) { setStoresLoading(false); setStoresLoadedPlatformId(platformId); }
}
})();
return () => {
cancelled = true;
};
}, [platformId, storesRetry]);
useEffect(() => {
const requestId = ++listRequestRef.current;
setItems([]);
setMeta(null);
setLoadedScope(null);
setRunScope(null);
setLoadingMore(false);
setListError(null);
setLatestRun(null);
setHistoryError(null);
setHistoryLoading(false);
if (!platformId || !storeId) {
setLoading(false);
return;
}
setLoading(true);
void Promise.allSettled([
onlineProductsApi.list(platformId, storeId, filters),
onlineProductsApi.listSyncRuns(platformId, storeId),
]).then(([listResult, runsResult]) => {
if (requestId !== listRequestRef.current) return;
if (listResult.status === 'fulfilled') {
setItems(listResult.value.data);
setMeta(listResult.value.meta);
} else setListError(apiErrorMessage(listResult.reason));
setLoadedScope(`${platformId}:${storeId}`);
if (runsResult.status === 'fulfilled') {
setLatestRun(runsResult.value.data[0] ?? null);
setHistory(runsResult.value.data);
setHistoryCursor(runsResult.value.meta.next_cursor);
} else setHistoryError(apiErrorMessage(runsResult.reason));
setRunScope(`${platformId}:${storeId}`);
setLoading(false);
});
}, [platformId, storeId, filters, refreshVersion]);
useEffect(() => {
if (!platformId || !storeId || !isActive(visibleRun)) return;
const runId = visibleRun!.id;
let cancelled = false;
const timer = window.setInterval(() => {
void onlineProductsApi
.getSyncRun(platformId, runId)
.then((run) => {
if (cancelled) return;
setLatestRun(run);
setHistory((previous) =>
previous.map((item) => (item.id === run.id ? run : item))
);
if (!isActive(run)) setRefreshVersion((value) => value + 1);
})
.catch(() => {});
}, 2500);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [platformId, storeId, visibleRun?.id, visibleRun?.status]);
const loadMore = async () => {
if (!platformId || !storeId || !visibleMeta?.next_cursor || loadingMore) return;
const requestId = listRequestRef.current;
setLoadingMore(true);
try {
const result = await onlineProductsApi.list(
platformId,
storeId,
filters,
visibleMeta.next_cursor
);
if (requestId !== listRequestRef.current) return;
setItems((previous) => [...previous, ...result.data]);
setMeta(result.meta);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
if (requestId === listRequestRef.current) setLoadingMore(false);
}
};
const startSync = async () => {
if (!platformId || !storeId || !scopeKey || syncStarting || !canSync) return;
const requestScope = scopeKey;
setSyncStarting(true);
try {
const result = await onlineProductsApi.createSync(platformId, storeId);
if (scopeRef.current !== requestScope) return;
setRunScope(scopeKey);
setLatestRun(result.sync_run);
setHistory((previous) => [
result.sync_run,
...previous.filter((run) => run.id !== result.sync_run.id),
]);
message.success('已开始同步 Ozon 商品');
} catch (error) {
if (scopeRef.current !== requestScope) return;
if (apiErrorCode(error) === 'SYNC_ALREADY_RUNNING') {
setRefreshVersion((value) => value + 1);
message.info('该店铺已有正在进行的商品同步');
} else message.error(apiErrorMessage(error));
} finally {
setSyncStarting(false);
}
};
const openDetail = useCallback(
async (item: OnlineProduct) => {
if (!platformId || !scopeKey) return;
const requestId = ++detailRequestRef.current;
setDetailOpen(true);
setDetail(item);
setDetailScope(scopeKey);
setDetailError(null);
setDetailLoading(true);
try {
const result = await onlineProductsApi.get(platformId, item.id);
if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetail(result);
} catch (error) {
if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetailError(apiErrorMessage(error));
} finally {
if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetailLoading(false);
}
},
[platformId, scopeKey]
);
const loadMoreHistory = async () => {
if (!platformId || !storeId || !scopeKey || !visibleHistoryCursor || historyLoading) return;
const requestScope = scopeKey;
setHistoryLoading(true);
setHistoryError(null);
try {
const result = await onlineProductsApi.listSyncRuns(platformId, storeId, visibleHistoryCursor);
if (scopeRef.current !== requestScope) return;
setHistory((previous) => [...previous, ...result.data]);
setHistoryCursor(result.meta.next_cursor);
} catch (error) {
if (scopeRef.current === requestScope) setHistoryError(apiErrorMessage(error));
} finally {
if (scopeRef.current === requestScope) setHistoryLoading(false);
}
};
if (platformError)
return (
<Alert
type="error"
showIcon
message="平台目录加载失败"
description={platformError}
action={<Button onClick={reloadPlatforms}>重试</Button>}
/>
);
return (
<div className="online-products-page">
<div className="online-products-heading">
<div>
<Typography.Title level={3}>在线商品</Typography.Title>
<Typography.Text type="secondary">
查看店铺在 {currentPlatform?.name ?? '当前平台'}{' '}
的实际商品状态。每行对应一个 SKU。
</Typography.Text>
</div>
<Space wrap>
<Select
className="online-products-store-select"
aria-label="选择店铺"
placeholder="选择店铺"
value={storeId}
loading={storesLoading || contextLoading}
onChange={(value) => {
if (platformId) setSelectedStore({ platformId, id: value });
detailRequestRef.current += 1;
setDetailOpen(false);
setHistoryOpen(false);
}}
options={scopedStores.map((store) => ({
value: store.id,
label: `${store.name}${store.is_default ? '(默认)' : ''}`,
}))}
disabled={scopedStores.length === 0}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={() => void startSync()}
loading={syncStarting}
disabled={!canSync || isActive(visibleRun)}
>
同步 Ozon 商品
</Button>
</Space>
</div>
{storesLoadedPlatformId === platformId && storesError && (
<Alert
type="error"
showIcon
message="店铺加载失败"
description={storesError}
action={
<Button onClick={() => setStoresRetry((value) => value + 1)}>重试</Button>
}
/>
)}
{!storesLoading &&
!contextLoading &&
!!platformId &&
storesLoadedPlatformId === platformId &&
!storesError &&
scopedStores.length === 0 ? (
<Card>
<Alert
type="info"
showIcon
message="还没有店铺"
description="先在店铺管理中连接 Ozon 店铺,才能同步在线商品。"
action={
<Button type="primary" onClick={() => navigate('/shops')}>
前往店铺管理
</Button>
}
/>
</Card>
) : (
<>
{store && !canSync && (
<Alert
type="warning"
showIcon
message="当前店铺暂不可同步"
description="请在店铺管理中检查店铺状态与 Seller API 凭据。"
/>
)}
{storeId && (
<>
<OnlineSyncStatus
meta={visibleMeta}
latestRun={visibleRun}
history={visibleHistory}
historyOpen={historyOpen}
historyLoading={historyLoading}
historyError={visibleHistoryError}
historyCursor={visibleHistoryCursor}
onOpenHistory={() => setHistoryOpen(true)}
onCloseHistory={() => setHistoryOpen(false)}
onMoreHistory={() => void loadMoreHistory()}
onRetryHistory={() => setRefreshVersion((value) => value + 1)}
/>
<Filters
filters={filters}
onChange={setFilters}
disabled={loading && visibleItems.length === 0}
/>
{visibleListError && (
<Alert
type="error"
showIcon
message="在线商品加载失败"
description={visibleListError}
action={
<Button
onClick={() => setRefreshVersion((value) => value + 1)}
>
重试
</Button>
}
/>
)}
<OnlineProductTable
items={visibleItems}
loading={loading}
nextCursor={visibleMeta?.next_cursor ?? null}
loadingMore={loadingMore}
onMore={() => void loadMore()}
onDetail={(item) => void openDetail(item)}
emptyText={
visibleMeta?.last_successful_sync_at
? '该店铺暂无符合条件的商品'
: '尚未同步商品,点击右上角“同步 Ozon 商品”'
}
/>
</>
)}
</>
)}
<OnlineProductDetailDrawer
open={detailOpen && detailScope === scopeKey}
item={detail}
loading={detailLoading}
error={detailError}
onClose={() => {
detailRequestRef.current += 1;
setDetailOpen(false);
}}
/>
</div>
);
}
@@ -0,0 +1,121 @@
import { Alert, Descriptions, Drawer, Image, Space, Spin, Tag, Typography } from 'antd';
import type { OnlineProduct } from '@atlas/contracts';
import { formatOnlineTime, moderationView, visibilityView } from './OnlineProductTable';
type Props = {
open: boolean;
item: OnlineProduct | null;
loading: boolean;
error: string | null;
onClose: () => void;
};
function visibilityReason(item: OnlineProduct): string {
if (item.visible === null) return '平台尚未返回可见性';
if (item.visible) return '商品当前可见';
const details = item.visibility_details;
const reasons = [
details.has_price === false ? '缺少价格' : null,
details.has_stock === false ? '暂无库存' : null,
details.active_product === false ? '商品未激活' : null,
].filter(Boolean);
return reasons.length ? reasons.join('、') : '平台当前不可见,请结合审核与错误提示查看';
}
export function OnlineProductDetailDrawer({ open, item, loading, error, onClose }: Props) {
return (
<Drawer title="商品详情" open={open} onClose={onClose} width={440} destroyOnHidden>
{loading && <Spin />}
{error && <Alert type="error" showIcon message={error} />}
{item && (
<Space direction="vertical" size="large" className="online-product-detail">
<div className="online-product-detail-heading">
{item.image?.url ? (
<Image
src={item.image.url}
alt={item.name ?? '商品图片'}
width={64}
height={64}
preview={false}
/>
) : (
<div className="online-product-detail-empty-image">无图</div>
)}
<div>
<Typography.Title level={5}>
{item.name || '未命名商品'}
</Typography.Title>
<Typography.Text type="secondary">{item.offer_id}</Typography.Text>
</div>
</div>
<Descriptions title="平台状态" column={1} size="small">
<Descriptions.Item label="可见性">
{visibilityView(item.visible)}
</Descriptions.Item>
<Descriptions.Item label="可见性说明">
{visibilityReason(item)}
</Descriptions.Item>
<Descriptions.Item label="库存合计">
{item.stock_summary.has_data ? item.stock_summary.total : '未知'}
</Descriptions.Item>
<Descriptions.Item label="审核状态">
{moderationView(item.moderation_status)}
</Descriptions.Item>
<Descriptions.Item label="售价">
{item.selling_price
? `${item.currency_code ?? ''} ${item.selling_price}`
: '—'}
</Descriptions.Item>
<Descriptions.Item label="平台状态">
{item.remote_state || '—'}
</Descriptions.Item>
<Descriptions.Item label="校验状态">
{item.validation_status || '—'}
</Descriptions.Item>
</Descriptions>
{item.errors.length > 0 && (
<Alert
type="warning"
showIcon
message="平台提示"
description={item.errors.map((error, index) => (
<div key={index}>{error.message}</div>
))}
/>
)}
<Descriptions title="商品标识" column={1} size="small">
<Descriptions.Item label="Offer ID">{item.offer_id}</Descriptions.Item>
<Descriptions.Item label="Ozon 商品 ID">
{item.remote_product_id}
</Descriptions.Item>
<Descriptions.Item label="Ozon SKU">
{item.remote_sku || '—'}
</Descriptions.Item>
<Descriptions.Item label="商品来源">
<Tag>
{item.source === 'atlas_published' ? 'Atlas 发布' : '外部同步'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Atlas 来源">
{item.atlas_origin ? (
<a href={`/products/${item.atlas_origin.product_id}/editor`}>
查看采集箱商品
</a>
) : (
'无关联'
)}
</Descriptions.Item>
<Descriptions.Item label="最近同步">
{formatOnlineTime(item.last_synced_at)}
</Descriptions.Item>
{item.missing_since && (
<Descriptions.Item label="首次未见">
{formatOnlineTime(item.missing_since)}
</Descriptions.Item>
)}
</Descriptions>
</Space>
)}
</Drawer>
);
}
@@ -0,0 +1,70 @@
import { Button, Input, Select } from 'antd';
import { useEffect, useState } from 'react';
import type { OnlineProductFilters as Filters } from '@/services/onlineProducts';
type Props = { filters: Filters; onChange: (next: Filters) => void; disabled?: boolean };
export function OnlineProductFilters({ filters, onChange, disabled }: Props) {
const [search, setSearch] = useState(filters.q);
useEffect(() => setSearch(filters.q), [filters.q]);
return (
<div className="online-products-filters">
<Input.Search
value={search}
onChange={(event) => {
setSearch(event.target.value);
if (!event.target.value && filters.q) onChange({ ...filters, q: '' });
}}
onSearch={(value) => onChange({ ...filters, q: value.trim() })}
placeholder="搜索名称、Offer ID、商品 ID、SKU"
aria-label="搜索在线商品"
allowClear
disabled={disabled}
className="online-products-search"
/>
<Select
value={filters.visibility}
onChange={(visibility) => onChange({ ...filters, visibility })}
options={[
{ value: 'all', label: '全部可见性' },
{ value: 'visible', label: '可见' },
{ value: 'invisible', label: '不可见' },
{ value: 'unknown', label: '未知' },
]}
aria-label="筛选可见性"
disabled={disabled}
className="online-products-filter"
/>
<Select
value={filters.source}
onChange={(source) => onChange({ ...filters, source })}
allowClear
placeholder="全部来源"
options={[
{ value: 'atlas_published', label: 'Atlas 发布' },
{ value: 'external_sync', label: '外部同步' },
]}
aria-label="筛选来源"
disabled={disabled}
className="online-products-filter"
/>
<Select
value={filters.moderation_status}
onChange={(moderation_status) => onChange({ ...filters, moderation_status })}
allowClear
placeholder="全部审核"
options={[
{ value: 'approved', label: '已通过' },
{ value: 'pending', label: '待审核' },
{ value: 'declined', label: '未通过' },
]}
aria-label="筛选审核状态"
disabled={disabled}
className="online-products-filter"
/>
<Button onClick={() => onChange({ q: '', visibility: 'all' })} disabled={disabled}>
重置
</Button>
</div>
);
}
@@ -0,0 +1,145 @@
import { Button, Empty, Image, Table, Tag, Typography } from 'antd';
import type { TableProps } from 'antd';
import type { OnlineProduct } from '@atlas/contracts';
type Props = {
items: OnlineProduct[];
loading: boolean;
nextCursor: string | null;
loadingMore: boolean;
onMore: () => void;
onDetail: (item: OnlineProduct) => void;
emptyText: string;
};
export function visibilityView(value: boolean | null) {
if (value === true) return <Tag color="green">可见</Tag>;
if (value === false) return <Tag color="red">不可见</Tag>;
return <Tag>未知</Tag>;
}
export function moderationView(value: string | null) {
if (value === 'approved') return <Tag color="green">已通过</Tag>;
if (value === 'declined') return <Tag color="red">未通过</Tag>;
if (value === 'pending') return <Tag color="gold">待审核</Tag>;
return <Tag>{value || '未知'}</Tag>;
}
export function formatOnlineTime(value: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
}
export function OnlineProductTable({
items,
loading,
nextCursor,
loadingMore,
onMore,
onDetail,
emptyText,
}: Props) {
const columns: TableProps<OnlineProduct>['columns'] = [
{
title: '商品',
key: 'product',
width: 270,
render: (_, item) => (
<div className="online-product-name-cell">
<div className="online-product-image">
{item.image?.url ? (
<Image
src={item.image.url}
alt={item.name ?? '商品图片'}
width={44}
height={44}
preview={false}
/>
) : (
<span>无图</span>
)}
</div>
<div className="online-product-name-text">
<Typography.Text strong ellipsis title={item.name ?? '未命名商品'}>
{item.name || '未命名商品'}
</Typography.Text>
<Typography.Text type="secondary">
SKU {item.remote_sku || '—'}
{item.missing_since ? ' · 最近同步未见' : ''}
</Typography.Text>
</div>
</div>
),
},
{
title: 'Offer ID / 商品 ID',
key: 'ids',
width: 200,
render: (_, item) => (
<div className="online-product-ids">
<Typography.Text copyable={{ text: item.offer_id }}>
{item.offer_id}
</Typography.Text>
<Typography.Text type="secondary">ID {item.remote_product_id}</Typography.Text>
</div>
),
},
{ title: '可见性', dataIndex: 'visible', width: 95, render: visibilityView },
{
title: '库存',
dataIndex: 'stock_summary',
width: 80,
render: (stock: OnlineProduct['stock_summary']) =>
stock?.has_data ? stock.total : '—',
},
{ title: '审核', dataIndex: 'moderation_status', width: 105, render: moderationView },
{
title: '售价',
key: 'price',
width: 105,
render: (_, item) =>
item.selling_price
? `${item.currency_code === 'RUB' ? '₽ ' : ''}${item.selling_price}`
: '—',
},
{
title: '来源',
dataIndex: 'source',
width: 100,
render: (source: OnlineProduct['source']) =>
source === 'atlas_published' ? 'Atlas 发布' : '外部同步',
},
{ title: '最近同步', dataIndex: 'last_synced_at', width: 170, render: formatOnlineTime },
{
title: '操作',
key: 'action',
width: 75,
fixed: 'right',
render: (_, item) => (
<Button type="link" size="small" onClick={() => onDetail(item)}>
详情
</Button>
),
},
];
return (
<div className="online-products-list">
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
scroll={{ x: 1220 }}
locale={{ emptyText: <Empty description={emptyText} /> }}
/>
<div className="online-products-list-footer">
<Typography.Text type="secondary">已显示 {items.length} 条商品</Typography.Text>
{nextCursor && (
<Button loading={loadingMore} onClick={onMore}>
加载更多
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,146 @@
import { Alert, Button, Drawer, Empty, List, Space, Tag, Typography } from 'antd';
import type { OnlineProductSyncRun, OnlineProductsMeta } from '@atlas/contracts';
import { formatOnlineTime } from './OnlineProductTable';
type Props = {
meta: OnlineProductsMeta | null;
latestRun: OnlineProductSyncRun | null;
history: OnlineProductSyncRun[];
historyOpen: boolean;
historyLoading: boolean;
historyError: string | null;
historyCursor: string | null;
onOpenHistory: () => void;
onCloseHistory: () => void;
onMoreHistory: () => void;
onRetryHistory: () => void;
};
const runLabel: Record<string, string> = {
queued: '等待同步',
running: '正在同步',
succeeded: '同步成功',
failed: '同步失败',
cancelled: '已取消',
};
export function OnlineSyncStatus({
meta,
latestRun,
history,
historyOpen,
historyLoading,
historyError,
historyCursor,
onOpenHistory,
onCloseHistory,
onMoreHistory,
onRetryHistory,
}: Props) {
const status = latestRun?.status ?? meta?.last_sync_status;
const alertType =
status === 'failed'
? 'warning'
: status === 'queued' || status === 'running'
? 'info'
: 'success';
const description =
status === 'failed'
? `最近同步失败,已保存的商品继续显示。上次完整成功:${formatOnlineTime(meta?.last_successful_sync_at ?? null)}`
: status === 'running' || status === 'queued'
? `同步中,当前列表展示已保存的商品镜像。上次完整成功:${formatOnlineTime(meta?.last_successful_sync_at ?? null)}`
: meta?.last_successful_sync_at
? `上次成功:${formatOnlineTime(meta.last_successful_sync_at)} · 当前展示同步镜像`
: '尚未同步过该店铺的 Ozon 商品';
return (
<>
<Alert
className="online-products-sync-status"
type={alertType}
showIcon
message={
<Space wrap>
<Typography.Text strong>商品同步</Typography.Text>
<Tag
color={
status === 'failed'
? 'orange'
: status === 'succeeded'
? 'green'
: 'blue'
}
>
{status ? (runLabel[status] ?? status) : '尚未同步'}
</Tag>
{latestRun && (status === 'running' || status === 'queued') && (
<Typography.Text type="secondary">
已读取 {latestRun.listed_count} · 已保存 {latestRun.upserted_count}
</Typography.Text>
)}
</Space>
}
description={description}
action={
<Button type="link" onClick={onOpenHistory}>
同步记录
</Button>
}
/>
<Drawer title="同步记录" open={historyOpen} onClose={onCloseHistory} width={440}>
{historyError && (
<Alert
type="error"
showIcon
message="同步记录加载失败"
description={historyError}
action={<Button onClick={onRetryHistory}>重试</Button>}
/>
)}
<List
loading={historyLoading}
dataSource={history}
locale={{ emptyText: <Empty description="暂无同步记录" /> }}
renderItem={(run) => (
<List.Item>
<List.Item.Meta
title={
<Space>
<span>{formatOnlineTime(run.created_at)}</span>
<Tag
color={
run.status === 'succeeded'
? 'green'
: run.status === 'failed'
? 'red'
: 'blue'
}
>
{runLabel[run.status] ?? run.status}
</Tag>
</Space>
}
description={
<>
<div>
已读取 {run.listed_count},已保存 {run.upserted_count}
</div>
{run.error && (
<Typography.Text type="danger">
{run.error.message}
</Typography.Text>
)}
</>
}
/>
</List.Item>
)}
/>
{historyCursor && (
<Button block loading={historyLoading} onClick={onMoreHistory}>
加载更多
</Button>
)}
</Drawer>
</>
);
}
@@ -219,6 +219,7 @@
left: 220px;
z-index: 50;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
@@ -237,6 +238,7 @@
}
.footer-ai-actions {
display: flex;
min-width: 0;
}
/*
@@ -297,6 +299,9 @@
}
}
@media (max-width: 560px) {
.product-editor {
padding-bottom: 230px;
}
.editor-section .ant-card-body {
padding: 20px 16px;
}
@@ -35,6 +35,7 @@ import { ProductAttributeSection } from './components/ProductAttributeSection';
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
import { ProductOtherSection } from './components/ProductOtherSection';
import { ProductVariantSection } from './components/ProductVariantSection';
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
import {
ProductCopyComparisonModal,
SkuCopyAiModal,
@@ -1061,8 +1062,7 @@ const ProductEditPage = () => {
const showFutureFeature = (name: string) =>
modal.info({
title: `${name}将在下一阶段接入`,
content:
'当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。',
content: '单张 AI 编辑已可在 SKU 图片选择中使用。AI 套图生成仍在开发。',
okText: '知道了',
});
const validSupplyUrl =
@@ -1478,10 +1478,11 @@ const ProductEditPage = () => {
</Button>
<Button
icon={<FileTextOutlined />}
onClick={() => showFutureFeature('AI 生图')}
onClick={() => showFutureFeature('AI 套图生成')}
>
AI 生图
AI 套图生成
</Button>
<ProductImageTaskCenter productId={snapshot.product.id} />
</Space>
<Space className="footer-primary-actions">
<Button onClick={leave}>取消并关闭</Button>
@@ -186,6 +186,10 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.collected-image-card {
position: relative;
min-width: 0;
}
.collected-image-grid .ant-image {
display: block;
overflow: hidden;
@@ -197,7 +201,18 @@
display: block;
width: 100%;
aspect-ratio: 3 / 4;
object-fit: cover;
object-fit: contain;
}
.collected-image-edit.ant-btn {
position: absolute;
right: 5px;
bottom: 5px;
z-index: 2;
height: 25px;
padding: 0 6px;
background: rgba(255, 255, 255, 0.94);
font-size: 11px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
@container (max-width: 1099px) {
/* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */
@@ -1,22 +1,35 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
App as AntdApp,
Breadcrumb,
Button,
Card,
Collapse,
Dropdown,
Image,
Input,
Space,
Tag,
Typography,
} from 'antd';
import type { MenuProps } from 'antd';
import { DownOutlined, LoadingOutlined } from '@ant-design/icons';
import type {
CollectionItemInspection,
ProductAiSourceField,
ProductAiSourceMaterial,
ProductEditorSnapshot,
ProductImageDerivativeRecord,
WatermarkTemplate,
} from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { createThreeFourImage, createWatermarkedImage, downloadMediaAsset, mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
import { resolveSkuImageSelection } from './skuImageSelection';
import type { SkuEditorImage } from './SkuImageCard';
import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer';
import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal';
import './CollectedProductInfoPanel.css';
export type CollectedProductSummary = {
@@ -48,7 +61,7 @@ type Props = {
/**
* 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述,
* 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图
* 分组折叠展示的采集图片(只读,点击可放大预览)。
* 分组折叠展示的采集图片及其商品级处理结果。
*
* 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口);
* 编辑、恢复、采用最新等动作均通过回调交由页面处理。
@@ -66,6 +79,28 @@ export function CollectedProductInfoPanel({
onShowDifferences,
onAcceptLatest,
}: Props) {
const { message } = AntdApp.useApp();
const [processedRecords, setProcessedRecords] = useState<ProductImageDerivativeRecord[]>([]);
const [historyError, setHistoryError] = useState(false);
const [resolvedTransfers, setResolvedTransfers] = useState<Record<string, string>>({});
const [busyImageId, setBusyImageId] = useState<string | null>(null);
const [aiTarget, setAiTarget] = useState<SkuEditorImage | null>(null);
const [watermarkTarget, setWatermarkTarget] = useState<SkuEditorImage | null>(null);
const actionLock = useRef(false);
const defaultWatermarkRequest = useRef<Promise<WatermarkTemplate | null> | null>(null);
const refreshProcessed = useCallback(async () => {
setProcessedRecords(await mediaApi.listProductImageDerivatives(snapshot.product.id));
setHistoryError(false);
}, [snapshot.product.id]);
useEffect(() => {
setProcessedRecords([]);
setHistoryError(false);
setResolvedTransfers({});
defaultWatermarkRequest.current = watermarksApi.getDefault().catch(() => null);
void refreshProcessed().catch(() => setHistoryError(true));
const interval = window.setInterval(() => void refreshProcessed().catch(() => setHistoryError(true)), 8 * 60_000);
return () => window.clearInterval(interval);
}, [refreshProcessed]);
const sourceDescription = collected?.description ?? '';
// 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。
const capturedCategories = snapshot.source_material_state.latest.categories;
@@ -92,6 +127,111 @@ export function CollectedProductInfoPanel({
return { key, label, items };
})
.filter((group) => group.items.length > 0);
/** 派生图保留原素材;按处理链的最新成功结果恢复采集区的显示图。 */
const roots = new Map<string, string>();
const latestByRoot = new Map<string, ProductImageDerivativeRecord>();
for (const record of [...processedRecords].reverse()) {
const root = roots.get(record.source_asset_id) ?? record.source_asset_id;
roots.set(record.output_asset.id, root);
latestByRoot.set(root, record);
}
const imageFor = (item: CollectionItemInspection['asset_transfers'][number], label: string): SkuEditorImage => {
const originalAssetId = item.asset_id ?? resolvedTransfers[item.id];
const latest = originalAssetId ? latestByRoot.get(originalAssetId) : undefined;
return {
id: `collection:${item.id}`,
url: latest?.output_asset.preview_url ?? item.source_url,
label,
origin: latest ? 'derivative' : 'collection',
assetId: latest?.output_asset.id ?? originalAssetId ?? undefined,
transferId: item.id,
};
};
const images = imageGroups.flatMap((group) => group.items.map((item) => imageFor(item, group.label)));
const resolveAsset = async (image: SkuEditorImage) => {
const [assetId] = await resolveSkuImageSelection([image], collectionRecord?.id ?? null);
if (image.transferId && !image.assetId) {
setResolvedTransfers((current) => ({ ...current, [image.transferId!]: assetId }));
}
return assetId;
};
const recordResult = (kind: ProductImageDerivativeRecord['kind'], sourceAssetId: string,
result: { id: string; output_asset: ProductImageDerivativeRecord['output_asset'] | null }) => {
if (!result.output_asset) throw new Error('图片处理结果不可用');
const record: ProductImageDerivativeRecord = {
id: result.id, kind, source_asset_id: sourceAssetId,
created_at: new Date().toISOString(), output_asset: result.output_asset,
};
setProcessedRecords((current) => [record, ...current.filter((item) => item.id !== record.id)]);
void refreshProcessed().catch(() => setHistoryError(true));
};
const processImage = async (image: SkuEditorImage, kind: 'aspect_ratio_3_4' | 'watermark',
template?: WatermarkTemplate) => {
if (actionLock.current) return;
actionLock.current = true;
setBusyImageId(image.id);
try {
const assetId = await resolveAsset(image);
if (kind === 'watermark' && !template) throw new Error('请选择水印模板');
const result = kind === 'watermark'
? await createWatermarkedImage(assetId, template!.id, snapshot.product.id)
: await createThreeFourImage(assetId, snapshot.product.id);
recordResult(kind, assetId, result);
message.success(kind === 'watermark' ? '水印图片已替换当前展示图' : '3:4 图片已替换当前展示图');
} finally {
actionLock.current = false;
setBusyImageId(null);
}
};
const addWatermark = async (image: SkuEditorImage) => {
if (actionLock.current) return;
actionLock.current = true;
setBusyImageId(image.id);
try {
const template = await (defaultWatermarkRequest.current
?? watermarksApi.getDefault().catch(() => null));
if (template) {
actionLock.current = false;
await processImage(image, 'watermark', template);
} else {
setWatermarkTarget(image);
}
} finally {
actionLock.current = false;
setBusyImageId(null);
}
};
const downloadImage = async (image: SkuEditorImage) => {
if (actionLock.current) return;
actionLock.current = true;
setBusyImageId(image.id);
try {
await downloadMediaAsset(await resolveAsset(image));
} catch (error) {
message.error(`${image.label} 下载失败:${apiErrorMessage(error)}`);
} finally {
actionLock.current = false;
setBusyImageId(null);
}
};
const imageMenu = (image: SkuEditorImage): MenuProps => ({
onClick: ({ key }) => {
if (actionLock.current) return;
if (key === 'ai') setAiTarget(image);
else if (key === 'watermark') void addWatermark(image).catch((error) =>
message.error(`添加水印失败:${apiErrorMessage(error)}`));
else if (key === 'three_four') void processImage(image, 'aspect_ratio_3_4').catch((error) =>
message.error(`${image.label} 处理失败:${apiErrorMessage(error)}`));
else if (key === 'download') void downloadImage(image);
},
items: [
{ key: 'ai', label: 'AI 编辑' },
{ key: 'watermark', label: '添加水印' },
{ key: 'three_four', label: '一键 3:4' },
{ key: 'download', label: '下载' },
],
});
return (
<aside className="editor-aside">
<Card className="collected-info-panel" bordered={false}>
@@ -239,6 +379,9 @@ export function CollectedProductInfoPanel({
</div>
<div className="collected-field">
<span>采集图片</span>
{historyError && <Alert className="collected-image-history-error" type="warning"
message="图片处理记录加载失败,部分处理图可能暂时显示为原图"
action={<Button size="small" onClick={() => void refreshProcessed().catch(() => setHistoryError(true))}>重试</Button>} />}
{imageGroups.length ? (
<Collapse
className="collected-image-groups"
@@ -250,13 +393,19 @@ export function CollectedProductInfoPanel({
label: `${label}(${items.length})`,
children: (
<div className="collected-image-grid">
{items.map((item) => (
<Image
key={item.id}
src={item.source_url}
alt={label}
/>
))}
{items.map((item) => {
const image = imageFor(item, label);
return <div className="collected-image-card" key={item.id}>
<Image src={image.url} alt={label} />
<Dropdown menu={imageMenu(image)} trigger={['click']} placement="bottomRight">
<Button className="collected-image-edit" size="small"
disabled={!!busyImageId}>
{busyImageId === image.id ? <LoadingOutlined /> : null}
编辑 <DownOutlined />
</Button>
</Dropdown>
</div>;
})}
</div>
),
}))}
@@ -266,6 +415,23 @@ export function CollectedProductInfoPanel({
)}
</div>
</Card>
<SkuWatermarkPickerModal
open={!!watermarkTarget}
onCancel={() => setWatermarkTarget(null)}
onApply={async (template) => {
if (watermarkTarget) await processImage(watermarkTarget, 'watermark', template);
}}
/>
<SkuSingleAiEditDrawer
open={!!aiTarget}
productId={snapshot.product.id}
source={aiTarget}
referenceOptions={images}
onClose={() => setAiTarget(null)}
onResolveAsset={resolveAsset}
onGenerated={async () => undefined}
footerHint="生成图将保存到当前商品的 AI 生图记录,可在 SKU 图片选择中使用"
/>
</aside>
);
}
@@ -0,0 +1,83 @@
.product-image-task-trigger {
display: inline-flex;
}
.product-image-task-section + .product-image-task-section {
margin-top: 24px;
}
.product-image-task-section h3 {
margin: 0 0 12px;
font-size: 15px;
}
.product-image-task-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
padding: 14px;
border: 1px solid #e9e5f0;
border-radius: 10px;
background: #fff;
}
.product-image-task-heading,
.product-image-task-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-image-task-heading .ant-tag {
margin: 0;
}
.product-image-task-meta {
font-size: 12px;
}
.product-image-task-failure-diagnosis {
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.product-image-task-actions {
justify-content: flex-end;
}
.product-image-task-actions:empty {
display: none;
}
.product-image-task-provider-error pre {
max-height: 220px;
margin: 6px 0 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #f7f6fa;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.product-image-task-error {
margin-bottom: 16px;
}
.product-image-task-results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 12px;
}
@media (max-width: 560px) {
.product-image-task-trigger,
.product-image-task-trigger .ant-btn {
width: 100%;
}
}
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, App as AntdApp, Badge, Button, Drawer, Empty, Image, Modal, Popconfirm, Progress, Spin, Tag, Typography } from 'antd';
import { ClockCircleOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons';
import type { ProductImageTask } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi } from '@/services/media';
import './ProductImageTaskCenter.css';
const { Text } = Typography;
const activeStatuses = new Set<ProductImageTask['status']>(['queued', 'running']);
const statusLabels: Record<ProductImageTask['status'], string> = {
queued: '排队中', running: '处理中', succeeded: '已完成', partial: '部分完成',
failed: '失败', cancelled: '已取消',
};
const statusColors: Record<ProductImageTask['status'], string> = {
queued: 'default', running: 'processing', succeeded: 'success', partial: 'warning',
failed: 'error', cancelled: 'default',
};
function taskTitle(task: ProductImageTask): string {
return task.kind === 'single_ai_edit' ? '单张 AI 编辑' : 'AI 套图生成';
}
function taskTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
/** 商品级图片任务入口;任务列表只传素材 ID,预览时再签发短时地址。 */
export function ProductImageTaskCenter({ productId }: { productId: string }) {
const { message } = AntdApp.useApp();
const [open, setOpen] = useState(false);
const [tasks, setTasks] = useState<ProductImageTask[]>([]);
const latestTasks = useRef<ProductImageTask[]>([]);
const requestSeq = useRef(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [preview, setPreview] = useState<{ title: string; urls: string[] } | null>(null);
const [previewLoadingId, setPreviewLoadingId] = useState<string | null>(null);
const [cancellingId, setCancellingId] = useState<string | null>(null);
const refresh = useCallback(async (): Promise<ProductImageTask[]> => {
const sequence = ++requestSeq.current;
try {
const current = await mediaApi.listProductImageTasks(productId);
if (sequence === requestSeq.current) {
latestTasks.current = current;
setTasks(current);
setError('');
}
return current;
} catch (cause) {
if (sequence === requestSeq.current) setError(apiErrorMessage(cause));
return latestTasks.current;
} finally {
if (sequence === requestSeq.current) setLoading(false);
}
}, [productId]);
useEffect(() => {
requestSeq.current += 1;
latestTasks.current = [];
setTasks([]);
setLoading(true);
const submitted = (event: Event) => {
const detail = (event as CustomEvent<{ productId: string }>).detail;
if (detail?.productId === productId) void refresh();
};
void refresh();
window.addEventListener('atlas:product-image-task-submitted', submitted);
return () => {
requestSeq.current += 1;
window.removeEventListener('atlas:product-image-task-submitted', submitted);
};
}, [productId, refresh]);
const hasActive = tasks.some((task) => activeStatuses.has(task.status));
useEffect(() => {
const timer = window.setInterval(() => void refresh(), hasActive ? 3000 : 15000);
return () => window.clearInterval(timer);
}, [hasActive, refresh]);
const cancel = async (task: ProductImageTask) => {
setCancellingId(task.id);
try {
await mediaApi.cancelAiEdit(task.id);
await refresh();
message.success('任务已取消');
} catch (cause) {
message.error(apiErrorMessage(cause));
} finally {
setCancellingId(null);
}
};
const showResult = async (task: ProductImageTask) => {
setPreviewLoadingId(task.id);
try {
const accesses = await Promise.all(task.output_asset_ids.map((id) => mediaApi.createPreviewUrl(id)));
setPreview({ title: taskTitle(task), urls: accesses.map((access) => access.url) });
} catch (cause) {
message.error(apiErrorMessage(cause));
} finally {
setPreviewLoadingId(null);
}
};
const active = tasks.filter((task) => activeStatuses.has(task.status));
const recent = tasks.filter((task) => !activeStatuses.has(task.status)).slice(0, 12);
const renderTask = (task: ProductImageTask) => <article className="product-image-task-card" key={`${task.kind}:${task.id}`}>
<div className="product-image-task-heading">
<strong>{taskTitle(task)}</strong>
<Tag color={statusColors[task.status]}>{statusLabels[task.status]}</Tag>
</div>
<Text type="secondary" className="product-image-task-meta">
<ClockCircleOutlined /> {taskTime(task.created_at)}
{task.phase === 'waiting_provider' ? ' · 等待 AI 服务返回' : ''}
</Text>
{activeStatuses.has(task.status) && <Progress
percent={Math.round(task.completed / Math.max(task.total, 1) * 100)}
size="small" status="active" format={() => `${task.completed}/${task.total}`}
/>}
{task.status === 'partial' && <Text type="secondary">已完成 {task.completed}/{task.total} 张</Text>}
{task.status === 'failed' && <Text type="danger">{mediaAiEditFailureMessage(task.error_code)}</Text>}
{task.status === 'failed' && <Text type="secondary" className="product-image-task-failure-diagnosis">
{mediaAiEditFailureDiagnosis(task)}
</Text>}
{task.status === 'failed' && task.provider_error_raw && <div className="product-image-task-provider-error">
<Text type="secondary">Pcore 原始错误</Text>
<pre>{task.provider_error_raw}</pre>
</div>}
<div className="product-image-task-actions">
{task.output_asset_ids.length > 0 && <Button type="link" htmlType="button"
loading={previewLoadingId === task.id} onClick={() => void showResult(task)}>查看结果</Button>}
{task.kind === 'single_ai_edit' && activeStatuses.has(task.status) && <Popconfirm
title="取消这个 AI 编辑任务?" okText="取消任务" cancelText="返回"
onConfirm={() => void cancel(task)}>
<Button type="link" danger htmlType="button" loading={cancellingId === task.id}>取消任务</Button>
</Popconfirm>}
</div>
</article>;
return <>
<Badge className="product-image-task-trigger" count={active.length} size="small">
<Button type="primary" htmlType="button"
icon={active.length ? <LoadingOutlined spin /> : <ClockCircleOutlined />}
onClick={() => setOpen(true)}>
{active.length ? '任务处理中' : '图片任务'}
</Button>
</Badge>
<Drawer title="当前商品的图片任务" placement="right" width="min(460px, 100vw)" open={open}
onClose={() => setOpen(false)}
extra={<Button htmlType="button" icon={<ReloadOutlined />} onClick={() => void refresh()}>刷新</Button>}>
{error && <Alert type="warning" showIcon message="任务列表暂时无法更新" description={error} className="product-image-task-error" />}
{loading && tasks.length === 0 ? <Spin /> : <>
<section className="product-image-task-section">
<h3>进行中 · {active.length}</h3>
{active.length ? active.map(renderTask) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无进行中的任务" />}
</section>
<section className="product-image-task-section">
<h3>最近任务</h3>
{recent.length ? recent.map(renderTask) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无任务记录" />}
</section>
</>}
</Drawer>
<Modal title={preview?.title} open={!!preview} onCancel={() => setPreview(null)} footer={null} width={760}>
<div className="product-image-task-results">
{preview?.urls.map((url) => <Image key={url} src={url} alt="AI 生成结果" />)}
</div>
</Modal>
</>;
}
@@ -121,6 +121,7 @@
font-size: 12px;
}
.sku-main-thumbnail {
position: relative;
display: flex;
flex: 0 0 auto;
align-self: flex-start;
@@ -137,13 +138,19 @@
font-size: 10px;
text-align: center;
}
.sku-main-thumbnail .ant-image,
.sku-main-thumbnail img {
.sku-main-thumbnail .ant-image {
position: absolute;
inset: 0 4px;
display: flex;
align-items: center;
justify-content: center;
}
.sku-main-thumbnail .ant-image img {
display: block;
width: 100%;
height: 100%;
}
.sku-main-thumbnail img {
object-fit: cover;
object-fit: contain;
object-position: center;
}
.sku-card-value {
flex: 0 0 auto;
@@ -222,7 +229,7 @@
.sku-media-item {
position: relative;
min-width: 0;
/* 每张图片框固定为 3:4;实际图片使用 contain,完整居中展示。 */
/* 图片框固定为 3:4;图片容器铺满框后用 contain 水平、垂直居中。 */
aspect-ratio: 3 / 4;
align-self: start;
overflow: hidden;
@@ -246,12 +253,16 @@
font-size: 11px;
line-height: 18px;
}
.sku-media-item .ant-image,
.sku-media-item img {
.sku-media-item .ant-image {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.sku-media-item .ant-image img {
width: 100%;
height: 100%;
}
.sku-media-item img {
display: block;
object-fit: contain;
object-position: center;
@@ -31,6 +31,9 @@ import type {
import { SectionTitle } from './SectionTitle';
import { DynamicAttributeFields } from './DynamicAttributeFields';
import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal';
import { createThreeFourImage, downloadMediaAsset } from '@/services/media';
import { createWatermarkedImage } from '@/services/media';
import type { WatermarkTemplate } from '@atlas/contracts';
import { resolveSkuImageSelection } from './skuImageSelection';
import { mediaApi, uploadProductImage } from '@/services/media';
import './ProductVariantSection.css';
@@ -293,6 +296,21 @@ export function ProductVariantSection({
})
.filter((image): image is SkuEditorImage => image !== null);
const selectedImages = localImagesByVariant[variantKey] ?? savedImages;
const sourceMainCandidate = !selectedAssetIds.length && !selectedImages.length && original
? imageCandidates.find((item) =>
item.source_variant_id === original.id &&
item.role === 'source_sku'
)
: null;
const displayImages: SkuEditorImage[] = sourceMainCandidate
? [{
id: `collection:${sourceMainCandidate.id}`,
url: sourceMainCandidate.source_url,
label: '来源 SKU 图片',
origin: 'collection',
transferId: sourceMainCandidate.id,
}]
: selectedImages;
const copyVariant = () =>
add({
...variantValue,
@@ -334,10 +352,10 @@ export function ProductVariantSection({
</Form.Item>
<div className="sku-card-main">
<div className="sku-main-thumbnail">
{selectedImages[0] ? (
{displayImages[0] ? (
<Image
preview={false}
src={selectedImages[0].url}
src={displayImages[0].url}
alt="SKU 主图"
/>
) : (
@@ -412,14 +430,15 @@ export function ProductVariantSection({
noStyle
rules={[
{
pattern: /^\d+(\.\d{1,4})?$/,
message: '请输入金额',
pattern: /^\d+(\.\d{1,2})?$/,
message: '请输入最多两位小数的金额',
},
]}
>
<InputNumber<string>
min="0"
step="0.0001"
step="0.01"
precision={2}
stringMode
placeholder="—"
/>
@@ -467,32 +486,37 @@ export function ProductVariantSection({
<Typography.Title level={5}>
SKU 图片
</Typography.Title>
{sourceMainCandidate && (
<Typography.Text className="sku-media-local-note">
来源 SKU 图片预览;选择后转存为正式主图
</Typography.Text>
)}
{Object.hasOwn(localImagesByVariant, variantKey) && (
<Typography.Text className="sku-media-local-note">
当前图片仅在本页预览,刷新后恢复
</Typography.Text>
)}
<div className="sku-media-grid">
{selectedImages.map((image, imageIndex) => (
{displayImages.map((image, imageIndex) => (
<div
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
key={image.id}
>
{imageIndex === 0 && (
<span className="sku-media-main-badge">
主图
{sourceMainCandidate ? '来源主图' : '主图'}
</span>
)}
<Image src={image.url} alt="已选 SKU 图片" />
</div>
))}
<Button
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`}
className={`sku-media-picker ${displayImages.length ? '' : 'sku-media-picker--empty'}`}
type="dashed"
onClick={() => setImageEditorTarget({
key: variantKey,
name: variantValue?.display_name || `SKU ${index + 1}`,
images: selectedImages,
images: displayImages,
candidates: imageCandidates,
})}
>
@@ -757,6 +781,7 @@ export function ProductVariantSection({
</Card>
<SkuImageEditorModal
open={!!imageEditorTarget}
productId={snapshot.product.id}
skuKey={imageEditorTarget?.key ?? ''}
skuName={imageEditorTarget?.name ?? ''}
initialImages={imageEditorTarget?.images ?? []}
@@ -791,6 +816,46 @@ export function ProductVariantSection({
assetId: asset.id,
};
}}
onMakeThreeFour={async (image) => {
const [assetId] = await resolveSkuImageSelection(
[image],
collectionRecord?.id ?? null
);
const derivative = await createThreeFourImage(assetId, snapshot.product.id);
const asset = derivative.output_asset;
if (!asset) throw new Error('图片处理结果不可用');
return {
id: `asset:${asset.id}`,
url: asset.preview_url,
label: '3:4 图片',
origin: 'derivative',
assetId: asset.id,
};
}}
onApplyWatermark={async (image, template: WatermarkTemplate) => {
const [assetId] = await resolveSkuImageSelection([image], collectionRecord?.id ?? null);
const derivative = await 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([image], collectionRecord?.id ?? null);
return assetId;
}}
onDownloadImage={async (image) => {
const [assetId] = await resolveSkuImageSelection(
[image],
collectionRecord?.id ?? null
);
await downloadMediaAsset(assetId);
}}
/>
</>
);
@@ -12,7 +12,7 @@ export type SkuEditorImage = {
id: string;
url: string;
label: string;
origin: 'saved' | 'collection' | 'upload' | 'network';
origin: 'saved' | 'collection' | 'upload' | 'network' | 'derivative';
assetId?: string;
transferId?: string;
sourceUrl?: string;
@@ -46,20 +46,11 @@
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;
margin-top: 16px;
min-height: 0;
flex: 1 1 auto;
}
@@ -140,6 +131,26 @@
gap: 12px;
padding: 3px;
}
.sku-image-processed-entry {
min-width: 0;
}
.sku-image-processed-caption {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 5px;
color: #77718a;
font-size: 11px;
line-height: 16px;
}
.sku-image-processed-caption strong {
color: #40394f;
}
.sku-image-processed-status {
display: flex;
justify-content: center;
padding: 32px;
}
.sku-image-selected-card,
.sku-image-source-card {
position: relative;
@@ -1,11 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
import type { MenuProps } from 'antd';
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
import type { CollectionItemInspection } from '@atlas/contracts';
import type { CollectionItemInspection, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
import { SkuImageCard, type SkuEditorImage } from './SkuImageCard';
import { SkuImageSourceTabs } from './SkuImageSourceTabs';
import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal';
import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer';
import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary';
import './SkuImageEditorModal.css';
@@ -15,6 +19,7 @@ export type { SkuEditorImage } from './SkuImageCard';
type Props = {
open: boolean;
productId: string;
skuKey: string;
skuName: string;
initialImages: SkuEditorImage[];
@@ -22,21 +27,14 @@ type Props = {
onCancel: () => void;
onConfirm: (images: SkuEditorImage[]) => Promise<void>;
onUploadFile: (file: File) => Promise<SkuEditorImage>;
onMakeThreeFour: (image: SkuEditorImage) => Promise<SkuEditorImage>;
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<SkuEditorImage>;
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
onDownloadImage: (image: SkuEditorImage) => Promise<void>;
};
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);
}
@@ -44,6 +42,7 @@ function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
/** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */
export function SkuImageEditorModal({
open,
productId,
skuKey,
skuName,
initialImages,
@@ -51,12 +50,45 @@ export function SkuImageEditorModal({
onCancel,
onConfirm,
onUploadFile,
onMakeThreeFour,
onApplyWatermark,
onResolveAsset,
onDownloadImage,
}: 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 [processingThreeFour, setProcessingThreeFour] = useState(false);
const [processingWatermark, setProcessingWatermark] = useState(false);
const [watermarkTargets, setWatermarkTargets] = useState<SkuEditorImage[]>([]);
const [watermarkUndo, setWatermarkUndo] = useState<Record<string, SkuEditorImage | null>>({});
const [aiTarget, setAiTarget] = useState<SkuEditorImage | null>(null);
const [aiImages, setAiImages] = useState<SkuEditorImage[]>([]);
const [processedRecords, setProcessedRecords] = useState<ProductImageDerivativeRecord[]>([]);
const [processedLoading, setProcessedLoading] = useState(false);
const [processedError, setProcessedError] = useState(false);
const refreshAiImages = useCallback(async () => {
const tasks = await mediaApi.listAiEdits(productId);
setAiImages(tasks.flatMap((task) => task.output_asset ? [{
id: `asset:${task.output_asset.id}`,
url: task.output_asset.preview_url,
label: 'AI 生成图', origin: 'derivative' as const,
assetId: task.output_asset.id,
}] : []));
}, [productId]);
const refreshProcessedImages = useCallback(async () => {
const records = await mediaApi.listProductImageDerivatives(productId);
setProcessedRecords(records);
setProcessedError(false);
}, [productId]);
const [downloading, setDownloading] = useState(false);
const processingLock = useRef(false);
const watermarkActionLock = useRef<symbol | null>(null);
const defaultWatermarkRequest = useRef<Promise<WatermarkTemplate | null> | null>(null);
const editorSession = useRef(0);
const downloadLock = useRef(false);
const {
networkUrls,
setNetworkUrls,
@@ -75,12 +107,68 @@ export function SkuImageEditorModal({
const dragIndex = useRef<number | null>(null);
useEffect(() => {
if (!open) return;
const session = ++editorSession.current;
watermarkActionLock.current = null;
defaultWatermarkRequest.current = watermarksApi.getDefault().catch(() => null);
setImages(initialImages);
setMarkedIds([]);
setActiveTab('collection');
setPreview(null);
setWatermarkTargets([]);
setWatermarkUndo({});
setAiTarget(null);
return () => {
if (editorSession.current === session) {
editorSession.current += 1;
defaultWatermarkRequest.current = null;
}
};
}, [open, skuKey]);
useEffect(() => {
if (!open) return;
let active = true;
const refresh = async () => {
const assetIds = [...new Set(images.flatMap((image) => image.assetId ? [image.assetId] : []))];
if (!assetIds.length) return;
const results = await Promise.allSettled(
assetIds.map((id) => mediaApi.createPreviewUrl(id))
);
if (!active) return;
const urls = new Map<string, string>();
results.forEach((result, index) => {
if (result.status === 'fulfilled') urls.set(assetIds[index], result.value.url);
});
if (urls.size) setImages((current) => current.map((image) => ({
...image,
url: image.assetId ? (urls.get(image.assetId) ?? image.url) : image.url,
})));
};
void refresh();
const interval = window.setInterval(() => void refresh(), 8 * 60_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [open, skuKey, images.map((image) => image.assetId ?? image.id).join('|')]);
useEffect(() => {
if (!open) return;
void refreshAiImages().catch(() => setAiImages([]));
const interval = window.setInterval(() => void refreshAiImages().catch(() => undefined), 8 * 60_000);
return () => window.clearInterval(interval);
}, [open, skuKey, refreshAiImages]);
useEffect(() => {
if (!open) return;
setProcessedLoading(true);
void refreshProcessedImages()
.catch(() => setProcessedError(true))
.finally(() => setProcessedLoading(false));
const interval = window.setInterval(() => {
void refreshProcessedImages().catch(() => setProcessedError(true));
}, 8 * 60_000);
return () => window.clearInterval(interval);
}, [open, skuKey, refreshProcessedImages]);
const addImage = (image: SkuEditorImage) => {
if (processingLock.current) return;
const existing = images.find((item) => sameImage(item, image));
if (existing) {
setImages((current) => current.filter((item) => item.id !== existing.id));
@@ -94,10 +182,12 @@ export function SkuImageEditorModal({
setImages((current) => [...current, image]);
};
const removeImages = (ids: string[]) => {
if (processingLock.current) return;
setImages((current) => current.filter((image) => !ids.includes(image.id)));
setMarkedIds((current) => current.filter((id) => !ids.includes(id)));
};
const setCover = (image: SkuEditorImage) => {
if (processingLock.current) return;
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
return;
@@ -107,26 +197,195 @@ export function SkuImageEditorModal({
return [selected, ...current.filter((item) => !sameImage(item, image))];
});
};
const markImage = (id: string) =>
const markImage = (id: string) => {
if (processingLock.current) return;
setMarkedIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id]
);
};
const makeThreeFour = async (targets: SkuEditorImage[]) => {
if (processingLock.current || !targets.length) return;
processingLock.current = true;
setProcessingThreeFour(true);
let succeeded = 0;
const selectedAssetIds = new Set(images.flatMap((image) => image.assetId ? [image.assetId] : []));
try {
for (const image of targets) {
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
break;
}
try {
const output = await onMakeThreeFour(image);
if (output.assetId && selectedAssetIds.has(output.assetId)
&& output.assetId !== image.assetId) {
message.info(`${image.label} 的 3:4 图片已在已选区,保留原图`);
continue;
}
if (image.assetId) selectedAssetIds.delete(image.assetId);
if (output.assetId) selectedAssetIds.add(output.assetId);
setImages((current) => {
if (sameImage(image, output)) return current;
const index = current.findIndex((item) => sameImage(item, image));
const withoutOutput = current.filter((item) => !sameImage(item, output));
if (index < 0) return [...withoutOutput, output].slice(0, MAX_IMAGES);
const replacement = withoutOutput.findIndex((item) => sameImage(item, image));
if (replacement < 0) return withoutOutput;
const next = [...withoutOutput];
next[replacement] = output;
return next;
});
setMarkedIds((current) => current.map((id) => id === image.id ? output.id : id));
succeeded += 1;
} catch (error) {
message.error(`${image.label} 处理失败:${apiErrorMessage(error)}`);
}
}
if (succeeded) {
message.success(`已生成 ${succeeded} 张 3:4 图片,请确认后保存草稿`);
void refreshProcessedImages().catch(() => setProcessedError(true));
}
} finally {
processingLock.current = false;
setProcessingThreeFour(false);
}
};
const applyWatermark = async (template: WatermarkTemplate, targets = watermarkTargets) => {
if (processingLock.current || !targets.length) return;
processingLock.current = true;
setProcessingWatermark(true);
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);
if (output.assetId && selectedAssetIds.has(output.assetId)
&& output.assetId !== image.assetId) {
message.info(`${image.label} 的水印图片已在已选区,保留原图`);
continue;
}
if (originalSelected && 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);
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) {
message.error(`${image.label} 添加水印失败:${apiErrorMessage(error)}`);
}
}
if (succeeded) {
message.success(`已生成 ${succeeded} 张水印图片,请确认后保存草稿`);
void refreshProcessedImages().catch(() => setProcessedError(true));
}
else throw new Error('没有图片成功添加水印,请检查模板和素材后重试');
} finally {
processingLock.current = false;
setProcessingWatermark(false);
}
};
/** 打开图片弹窗时预取默认模板;点击时等待同一次查询后决定是否需要选模板。 */
const addWatermark = async (targets: SkuEditorImage[]) => {
if (processingLock.current || watermarkActionLock.current || !targets.length) return;
const action = Symbol('add-watermark');
watermarkActionLock.current = action;
const session = editorSession.current;
try {
const request = defaultWatermarkRequest.current
?? watermarksApi.getDefault().catch(() => null);
defaultWatermarkRequest.current = request;
const template = await request;
if (session !== editorSession.current) return;
if (template) {
try {
await applyWatermark(template, targets);
} catch (error) {
message.error(`应用默认水印失败:${apiErrorMessage(error)}`);
}
} else {
setWatermarkTargets(targets);
}
} finally {
if (watermarkActionLock.current === action) watermarkActionLock.current = null;
}
};
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));
setWatermarkUndo((current) => {
const next = { ...current };
delete next[image.id];
return next;
});
};
const pendingAction = (label: string) => message.info(`${label}将在后续接入图片处理服务`);
const downloadImages = async (targets: SkuEditorImage[]) => {
if (downloadLock.current || !targets.length) return;
downloadLock.current = true;
setDownloading(true);
message.open({ type: 'loading', content: '正在准备图片下载…', key: 'sku-image-download', duration: 0 });
let succeeded = 0;
try {
for (const image of targets) {
try {
await onDownloadImage(image);
succeeded += 1;
} catch (error) {
message.error(`${image.label} 下载失败:${apiErrorMessage(error)}`);
}
}
if (targets.length > 1 && succeeded) message.success(`已发起 ${succeeded} 张图片下载`);
} finally {
message.destroy('sku-image-download');
downloadLock.current = false;
setDownloading(false);
}
};
const onBatchAction: MenuProps['onClick'] = ({ key }) => {
if (processingLock.current) return;
if (!markedIds.length) {
message.info('请先点击左侧图片进行选择');
return;
}
if (key === 'delete') removeImages(markedIds);
else if (key === 'download')
images.filter((image) => markedIds.includes(image.id)).forEach(downloadImage);
void downloadImages(images.filter((image) => markedIds.includes(image.id)));
else if (key === 'three_four')
void makeThreeFour(images.filter((image) => markedIds.includes(image.id)));
else if (key === 'watermark')
void addWatermark(images.filter((image) => markedIds.includes(image.id)));
else if (key === 'undo_watermark') images.filter((image) => markedIds.includes(image.id)).forEach(undoWatermark);
else pendingAction(String(key));
};
const batchMenu: MenuProps = {
onClick: onBatchAction,
items: [
{ key: '添加水印', label: '添加水印' },
{ key: '一键 3:4', label: '一键 3:4' },
{ key: 'watermark', label: '添加水印' },
{ key: 'undo_watermark', label: '撤销水印', disabled: !markedIds.some((id) => id in watermarkUndo) },
{ key: 'three_four', label: '一键 3:4' },
{ key: '批量翻译', label: '批量翻译' },
{ key: 'download', label: '下载' },
{ type: 'divider' },
@@ -136,20 +395,26 @@ export function SkuImageEditorModal({
const imageMenu = (image: SkuEditorImage): MenuProps => ({
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
if (processingLock.current) return;
if (key === 'cover') setCover(image);
else if (key === 'download') downloadImage(image);
else if (key === 'download') void downloadImages([image]);
else if (key === 'three_four') void makeThreeFour([image]);
else if (key === 'watermark') void addWatermark([image]);
else if (key === 'AI 编辑') setAiTarget(image);
else if (key === 'undo_watermark') undoWatermark(image);
else pendingAction(String(key));
},
items: [
{ key: 'AI 编辑', label: 'AI 编辑' },
{ key: '添加水印', label: '添加水印' },
{ key: '一键 3:4', label: '一键 3:4' },
{ key: 'watermark', label: '添加水印' },
...(image.id in watermarkUndo ? [{ key: 'undo_watermark', label: '撤销水印' }] : []),
{ key: 'three_four', label: '一键 3:4' },
{ key: 'cover', label: '设为封图' },
{ key: 'download', label: '下载' },
],
});
const uploadFiles = async (files: FileList | null) => {
if (!files) return;
if (!files || processingLock.current) return;
const available = MAX_IMAGES - images.length;
const accepted = Array.from(files).filter((file) => {
const error = uploadFileError(file);
@@ -210,6 +475,39 @@ export function SkuImageEditorModal({
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
);
const processedImages = processedRecords.map((record): SkuEditorImage => ({
id: `asset:${record.output_asset.id}`,
url: record.output_asset.preview_url,
label: record.kind === 'watermark' ? '水印处理图' : '3:4 处理图',
origin: 'derivative',
assetId: record.output_asset.id,
}));
const renderProcessedImages = () => {
if (processedLoading) return <div className="sku-image-processed-status"><Spin /></div>;
if (processedError) return <Empty description="图片处理记录加载失败">
<Button onClick={() => void refreshProcessedImages().catch(() => setProcessedError(true))}>重试</Button>
</Empty>;
if (!processedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
return <div className="sku-image-source-grid">
{processedRecords.map((record, index) => {
const image = processedImages[index];
return <div className="sku-image-processed-entry" key={record.id}>
<SkuImageCard
image={image}
mode="source"
selected={images.some((item) => sameImage(item, image))}
menu={imageMenu(image)}
onToggle={() => addImage(image)}
onPreview={() => showPreview(processedImages, index)}
/>
<div className="sku-image-processed-caption">
<strong>{record.kind === 'watermark' ? '水印' : '一键 3:4'}</strong>
<span>{new Date(record.created_at).toLocaleString('zh-CN')}</span>
</div>
</div>;
})}
</div>;
};
const confirmSelection = async () => {
setConfirming(true);
@@ -235,19 +533,19 @@ export function SkuImageEditorModal({
}
width="min(1480px, 96vw)"
onCancel={() => {
if (!confirming) onCancel();
if (!confirming && !processingThreeFour && !processingWatermark) onCancel();
}}
closable={!confirming}
maskClosable={!confirming}
closable={!confirming && !processingThreeFour && !processingWatermark}
maskClosable={!confirming && !processingThreeFour && !processingWatermark}
footer={[
<Button key="cancel" onClick={onCancel} disabled={confirming}>
<Button key="cancel" onClick={onCancel} disabled={confirming || processingThreeFour || processingWatermark}>
取消
</Button>,
<Button
key="confirm"
type="primary"
loading={confirming}
disabled={uploading}
disabled={uploading || processingThreeFour || processingWatermark}
onClick={() => void confirmSelection()}
>
确认
@@ -255,9 +553,6 @@ export function SkuImageEditorModal({
]}
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">
@@ -273,6 +568,7 @@ export function SkuImageEditorModal({
type="primary"
icon={<UploadOutlined />}
loading={uploading}
disabled={processingThreeFour || processingWatermark}
onClick={() => fileInput.current?.click()}
>
上传图片
@@ -285,18 +581,18 @@ export function SkuImageEditorModal({
) : undefined
}
onClick={() =>
setMarkedIds(
!processingLock.current && setMarkedIds(
markedIds.length === images.length
? []
: images.map((image) => image.id)
)
}
disabled={!images.length}
disabled={!images.length || processingThreeFour || processingWatermark}
>
全选
</Button>
<Dropdown menu={batchMenu} trigger={['click']}>
<Button>
<Button loading={processingThreeFour || processingWatermark || downloading} disabled={processingThreeFour || processingWatermark || downloading}>
批量操作 <DownOutlined />
</Button>
</Dropdown>
@@ -305,7 +601,7 @@ export function SkuImageEditorModal({
<strong>
已选图片({images.length} / {MAX_IMAGES})
</strong>
<span>第一张为主图,拖动可调整顺序</span>
<span>拖动可调整顺序</span>
</div>
<div className="sku-image-editor-scroll">
{images.length ? (
@@ -322,11 +618,13 @@ export function SkuImageEditorModal({
onPreview={() => showPreview(images, index)}
onRemove={() => removeImages([image.id])}
onDragStart={() => {
if (processingLock.current) return;
dragIndex.current = index;
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (processingLock.current) return;
const from = dragIndex.current;
if (from === null || from === index) return;
setImages((current) => {
@@ -351,9 +649,16 @@ export function SkuImageEditorModal({
<section className="sku-image-editor-right" aria-label="可选图片">
<SkuImageSourceTabs
activeTab={activeTab}
onTabChange={setActiveTab}
onTabChange={(tab) => {
setActiveTab(tab);
if (tab === 'processed') {
void refreshProcessedImages().catch(() => setProcessedError(true));
}
}}
sourceImages={sourceImages}
renderImages={renderSourceImages}
aiImages={aiImages}
renderProcessedImages={renderProcessedImages}
networkImages={networkImages}
networkUrls={networkUrls}
onNetworkUrlsChange={setNetworkUrls}
@@ -366,6 +671,51 @@ export function SkuImageEditorModal({
</section>
</div>
</Modal>
<SkuWatermarkPickerModal
open={open && watermarkTargets.length > 0}
onCancel={() => setWatermarkTargets([])}
onApply={applyWatermark}
/>
<SkuSingleAiEditDrawer
open={open && !!aiTarget}
productId={productId}
source={aiTarget}
referenceOptions={[
...images,
...sourceImages('source_main'),
...sourceImages('source_sku'),
...sourceImages('source_detail'),
...networkImages,
...uploadedImages,
]}
onClose={() => setAiTarget(null)}
onResolveAsset={onResolveAsset}
onGenerated={refreshAiImages}
onApply={(source, task) => {
const asset = task.output_asset;
if (!asset) return false;
const output: SkuEditorImage = {
id: `asset:${asset.id}`, url: asset.preview_url,
label: `${source.label} · AI 编辑`, origin: 'derivative', assetId: asset.id,
};
if (images.some((image) => sameImage(image, output))) {
message.info('这张 AI 生成图已在当前 SKU 已选图片中');
return false;
}
if (images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
return false;
}
setImages((current) => {
const index = current.findIndex((image) => sameImage(image, source));
const next = [...current];
next.splice(index < 0 ? next.length : index + 1, 0, output);
return next;
});
message.success('AI 生成图已加入当前 SKU,请确认选图后保存页面');
return true;
}}
/>
<Image.PreviewGroup
items={preview?.images.map((image) => image.url) ?? []}
preview={{
@@ -16,6 +16,8 @@ type Props = {
onTabChange: (tab: string) => void;
sourceImages: (role: string) => SkuEditorImage[];
renderImages: (images: SkuEditorImage[]) => ReactNode;
aiImages: SkuEditorImage[];
renderProcessedImages: () => ReactNode;
networkImages: SkuEditorImage[];
networkUrls: string;
onNetworkUrlsChange: (value: string) => void;
@@ -32,6 +34,8 @@ export function SkuImageSourceTabs({
onTabChange,
sourceImages,
renderImages,
aiImages,
renderProcessedImages,
networkImages,
networkUrls,
onNetworkUrlsChange,
@@ -50,12 +54,17 @@ export function SkuImageSourceTabs({
key: 'ai',
label: 'AI 生图记录',
children: (
<Empty
aiImages.length ? renderImages(aiImages) : <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无 AI 生图记录"
/>
),
},
{
key: 'processed',
label: '图片处理记录',
children: renderProcessedImages(),
},
{
key: 'collection',
label: '采集图片',
@@ -0,0 +1,39 @@
.sku-single-ai-drawer .ant-drawer-body { padding: 20px 24px; }
.sku-single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.sku-single-ai-media, .sku-single-ai-settings { min-width: 0; }
.sku-single-ai-section { margin-bottom: 16px; padding: 16px; border: 1px solid #e8e7ee; border-radius: 10px; background: #fff; }
.sku-single-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.sku-single-ai-head .ant-typography { font-size: 12px; }
.sku-single-ai-reference-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 9px; aspect-ratio: 5 / 4; }
.sku-single-ai-reference { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px dashed #d8d4e7; border-radius: 8px; background: #f7f6fa; }
.sku-single-ai-reference:first-child { grid-column: 1; grid-row: 1 / 3; }
.sku-single-ai-reference:nth-child(2) { grid-column: 2; grid-row: 1; }
.sku-single-ai-reference:nth-child(3) { grid-column: 2; grid-row: 2; }
.sku-single-ai-reference img { display: block; width: 100%; height: 100%; object-fit: contain; }
.sku-single-ai-reference > span { position: absolute; left: 0; right: 0; bottom: 0; padding: 5px; background: #292733b8; color: #fff; font-size: 11px; text-align: center; }
.sku-single-ai-reference > .ant-btn { position: absolute; top: 5px; right: 5px; }
.sku-single-ai-reference > button:not(.ant-btn) { width: 100%; height: 100%; border: 0; background: transparent; color: #7165d8; cursor: pointer; }
.sku-single-ai-reference > .sku-single-ai-change { position: absolute; right: 0; bottom: 25px; left: 0; height: 27px !important; background: #ffffffd9 !important; }
.sku-single-ai-result { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 390px; margin-inline: auto; overflow: hidden; border-radius: 8px; background: #f5f4f8; }
.sku-single-ai-result .ant-image, .sku-single-ai-result .ant-image-img { width: 100%; height: 100%; object-fit: contain; }
.sku-single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8b8798; text-align: center; }
.sku-single-ai-empty strong { color: #494556; }
.sku-single-ai-sparkle { color: #7465e6; font-size: 36px; }
.sku-single-ai-styles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.sku-single-ai-styles button { min-height: 34px; border: 1px solid #e1deeb; border-radius: 6px; background: #fff; cursor: pointer; }
.sku-single-ai-styles button.selected { border-color: #7062e9; background: #f2f0ff; color: #6254dc; }
.sku-single-ai-styles + .ant-btn, .sku-single-ai-section > .ant-btn-link { padding-left: 0; }
.sku-single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 78px; gap: 6px; margin-bottom: 8px; }
.sku-single-ai-dimensions .ant-input-number { width: 100%; }
.sku-single-ai-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.sku-single-ai-footer .ant-typography { font-size: 12px; }
.sku-single-ai-footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.sku-single-ai-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; max-height: 450px; overflow-y: auto; }
.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 6px; background: #fff; cursor: pointer; }
.sku-single-ai-picker img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; }
.sku-single-ai-picker span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 760px) {
.sku-single-ai-layout { grid-template-columns: 1fr; }
.sku-single-ai-footer { align-items: flex-start; flex-direction: column; }
.sku-single-ai-footer > div { width: 100%; }
}
@@ -0,0 +1,237 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, App as AntdApp, Button, Checkbox, Drawer, Image, Input, InputNumber, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import type { CreateMediaAiEditRequest, MediaAiEdit } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi, notifyProductImageTaskSubmitted } from '@/services/media';
import type { SkuEditorImage } from './SkuImageCard';
import './SkuSingleAiEditDrawer.css';
const { Text } = Typography;
const styles = [
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalist e-commerce style: warm ivory, soft daylight, natural texture and spacious composition.' },
{ value: 2, label: '清新明亮', prompt: 'Fresh bright e-commerce style: clean high-key daylight, natural colors and an airy retail composition.' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark editorial style: charcoal setting, sculpted side lighting, realistic material texture.' },
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle style: believable cream home setting, balanced golden and window light, product as the hero.' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean studio style: neutral seamless background, even softbox light, crisp edges and no decorative props.' },
];
type Settings = Required<CreateMediaAiEditRequest>;
type Ratio = Settings['ratio'];
type Dimensions = NonNullable<Settings['dimensions']>;
type Props = {
open: boolean;
productId: string;
source: SkuEditorImage | null;
referenceOptions: SkuEditorImage[];
onClose: () => void;
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
onApply?: (source: SkuEditorImage, result: MediaAiEdit) => boolean;
onGenerated: () => Promise<void>;
footerHint?: string;
};
export function SkuSingleAiEditDrawer({ open, productId, source, referenceOptions, onClose, onResolveAsset, onApply, onGenerated, footerHint }: Props) {
const { message } = AntdApp.useApp();
const [references, setReferences] = useState<(SkuEditorImage | null)[]>([null, null]);
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
const [prompt, setPrompt] = useState('');
const [ratio, setRatio] = useState<Ratio>('3:4');
const [translateText, setTranslateText] = useState(false);
const [language, setLanguage] = useState<Settings['target_language']>('ru');
const [background, setBackground] = useState<Settings['background']>('auto');
const [backgroundDescription, setBackgroundDescription] = useState('');
const [styleSet, setStyleSet] = useState(2);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [showStylePrompt, setShowStylePrompt] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ unit: 'cm' });
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<MediaAiEdit | null>(null);
const [error, setError] = useState('');
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
const [appliedId, setAppliedId] = useState<string | null>(null);
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
const stylePrompt = stylePrompts[styleSet] ?? styles.find((item) => item.value === styleSet)?.prompt ?? '';
const choices = useMemo(() => Array.from(new Map(referenceOptions
.filter((image) => image.url && image.id !== source?.id
&& !(image.assetId && source?.assetId && image.assetId === source.assetId))
.map((image) => [image.assetId ?? image.id, image])).values()), [referenceOptions, source]);
useEffect(() => {
if (!open || !source) return;
setReferences([null, null]);
setChoosingSlot(null);
setPrompt('');
setRatio('3:4');
setTranslateText(false);
setLanguage('ru');
setBackground('auto');
setBackgroundDescription('');
setStyleSet(2);
setStylePrompts({});
setDimensions({ unit: 'cm' });
setSizeChartOverride(null);
setResult(null);
setError('');
setAppliedId(null);
setTaskId(null);
}, [source?.id, open]);
useEffect(() => {
if (!taskId) return;
let active = true;
let timer: number | undefined;
let failures = 0;
const poll = async () => {
try {
const current = await mediaApi.readAiEdit(taskId);
if (!active) return;
failures = 0;
setError('');
if (current.status === 'succeeded' && current.output_asset) {
setResult(current);
setGenerating(false);
setTaskId(null);
void onGenerated().catch(() => undefined);
message.success('单张图片生成完成');
return;
}
if (current.status === 'failed' || current.status === 'cancelled') {
setError(current.status === 'cancelled' ? '任务已取消'
: `${mediaAiEditFailureMessage(current.error_code)} ${mediaAiEditFailureDiagnosis(current)}`);
setGenerating(false);
setTaskId(null);
return;
}
timer = window.setTimeout(() => void poll(), 3000);
} catch {
if (!active) return;
failures += 1;
if (failures >= 5) {
setError('查询任务进度暂时失败,正在继续重试;如需退出可先取消任务。');
}
timer = window.setTimeout(() => void poll(), Math.min(3000 * (2 ** Math.min(failures, 4)), 15000));
}
};
void poll();
return () => { active = false; if (timer) window.clearTimeout(timer); };
}, [taskId, message, onGenerated]);
const generate = async () => {
if (!source || generating) return;
if (background === 'reference' && !references.some(Boolean)) {
message.warning('请选择一张辅助参考图作为背景参考');
return;
}
setGenerating(true);
setResult(null);
setError('');
setAppliedId(null);
setResultRatio(ratio);
try {
const [sourceId, ...refIds] = await Promise.all([
onResolveAsset(source),
...references.flatMap((image) => image ? [onResolveAsset(image)] : []),
]);
const created = await mediaApi.createAiEdit(sourceId, {
product_id: productId, reference_asset_ids: refIds, prompt: prompt.trim(), mode: 'edit',
ratio,
translate_text: translateText,
target_language: language, background, background_description: backgroundDescription.trim(),
style_set: styleSet, style_prompt: stylePrompt,
dimensions: hasDimensions ? dimensions : undefined,
generate_size_chart: generateSizeChart,
});
setTaskId(created.id);
notifyProductImageTaskSubmitted(productId);
} catch (cause) {
setError(apiErrorMessage(cause));
setGenerating(false);
}
};
const cancelTask = async () => {
if (!taskId) return;
try {
await mediaApi.cancelAiEdit(taskId);
setTaskId(null);
setGenerating(false);
setError('任务已取消');
} catch (cause) { message.error(apiErrorMessage(cause)); }
};
const close = () => {
if (generating && taskId) message.info('任务会继续运行,可在底部操作栏的图片任务查看进度');
onClose();
};
const chooseReference = (image: SkuEditorImage) => {
if (choosingSlot === null) return;
setReferences((current) => current.map((item, index) => index === choosingSlot
? image : item?.id === image.id ? null : item));
setChoosingSlot(null);
};
return <>
<Drawer className="sku-single-ai-drawer" title="AI编辑"
placement="right" size="min(1040px, 100vw)" zIndex={1100} open={open} onClose={close}
footer={<div className="sku-single-ai-footer"><Text type="secondary">{footerHint ?? '生成图将作为独立素材,应用后仍需确认选图并保存页面'}</Text><div>
{generating && taskId && <Button danger onClick={() => void cancelTask()}>取消任务</Button>}
<Button onClick={close}>关闭</Button>
{onApply && result?.output_asset && <Button onClick={() => {
if (!source) return;
if (onApply(source, result)) setAppliedId(result.id);
}} disabled={appliedId === result.id}>{appliedId === result.id ? '已添加到当前 SKU' : '添加到当前 SKU'}</Button>}
<Button type="primary" loading={generating} disabled={!source} onClick={() => void generate()}>{result ? '重新生成 1 张' : '生成 1 张'}</Button>
</div></div>}>
<div className="sku-single-ai-layout">
<div className="sku-single-ai-media">
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>主图与辅助参考</strong><Text type="secondary">主图固定 · 辅图最多 2 张</Text></div>
<div className="sku-single-ai-reference-grid">
<div className="sku-single-ai-reference"><img src={source?.url} alt={source?.label ?? '主图'} /><span>主图 · 编辑底图</span></div>
{references.map((image, index) => <div className="sku-single-ai-reference" key={index}>
{image ? <><img src={image.url} alt={image.label} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setReferences((current) => current.map((item, slot) => slot === index ? null : item))} /><button type="button" className="sku-single-ai-change" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button></>
: <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /> 添加辅图 {index + 1}</button>}
</div>)}
</div>
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>AI 生成结果</strong><Text type="secondary">{resultRatio}</Text></div>
<div className="sku-single-ai-result" style={{ aspectRatio: resultRatio.replace(':', ' / ') }}>
{result?.output_asset ? <Image src={result.output_asset.preview_url} alt="AI 生成结果" />
: generating ? <div className="sku-single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会显示在这里</span></div>
: <div className="sku-single-ai-empty"><span className="sku-single-ai-sparkle">✦</span><strong>生成的图片会显示在这里</strong><span>原图会保留</span></div>}
</div>
{error && <Alert style={{ marginTop: 10 }} type="error" showIcon message={error} />}
</section>
</div>
<div className="sku-single-ai-settings">
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>编辑要求</strong></div><Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder="描述要改什么,例如:保留商品外观,换成温暖的客厅背景" /></section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
{translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} /><Text type="secondary">生成时识别并翻译图片文案,重新排版</Text></>}
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>背景</strong></div>
<Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[{ value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' }, { value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' }]} />
{['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>视觉风格</strong></div>
<div className="sku-single-ai-styles">{styles.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
<Button type="link" size="small" onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
{showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((current) => ({ ...current, [styleSet]: event.target.value }))} />}
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
<div className="sku-single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0.01} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((current) => ({ ...current, [key]: typeof value === 'number' && value > 0 ? value : undefined }))} />)}
<Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((current) => ({ ...current, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
{hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
<Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向</Text>
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>画面比例</strong></div>
<Radio.Group optionType="button" buttonStyle="solid" value={ratio} disabled={generating} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
</section>
</div>
</div>
</Drawer>
<Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} zIndex={1200} footer={null} onCancel={() => setChoosingSlot(null)}>
<div className="sku-single-ai-picker">{choices.length ? choices.map((image) => <button type="button" key={image.id} onClick={() => chooseReference(image)}><img src={image.url} alt={image.label} /><span>{image.label}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
</Modal>
</>;
}
@@ -0,0 +1,137 @@
.sku-watermark-picker-modal {
max-width: calc(100vw - 32px);
}
.sku-watermark-picker-modal .ant-modal-body {
padding: 0 24px 20px;
}
.sku-watermark-picker-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.sku-watermark-picker-toolbar strong {
font-size: 16px;
}
.sku-watermark-picker-scroll {
max-height: min(440px, 56dvh);
overflow-y: auto;
}
.sku-watermark-picker-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.sku-watermark-picker-card {
display: flex;
min-width: 0;
min-height: 112px;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e5e3eb;
border-radius: 8px;
background: #fff;
color: inherit;
cursor: pointer;
text-align: left;
}
.sku-watermark-picker-card:hover,
.sku-watermark-picker-card.is-selected {
border-color: #7062e9;
}
.sku-watermark-picker-card.is-selected {
background: #f7f6ff;
box-shadow: 0 0 0 2px rgb(112 98 233 / 12%);
}
.sku-watermark-picker-card:focus-visible {
outline: 2px solid #7062e9;
outline-offset: 2px;
}
.sku-watermark-picker-card img,
.sku-watermark-picker-image-empty {
width: 64px;
height: 76px;
flex: 0 0 64px;
border-radius: 4px;
object-fit: contain;
}
.sku-watermark-picker-image-empty {
display: grid;
place-items: center;
background: #f4f2f7;
color: #a29da9;
}
.sku-watermark-picker-card-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.sku-watermark-picker-card-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.sku-watermark-picker-card-heading strong,
.sku-watermark-picker-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sku-watermark-picker-card-heading strong {
flex: 1;
}
.sku-watermark-picker-card-heading .ant-tag {
flex: 0 0 auto;
margin-inline-end: 0;
}
.sku-watermark-picker-detail {
color: #777384;
font-size: 12px;
}
.sku-watermark-picker-loading {
display: grid;
min-height: 120px;
place-items: center;
}
.sku-watermark-picker-more {
margin-top: 16px;
text-align: center;
}
.sku-watermark-picker-unavailable {
margin: 12px 0 0;
color: #8d879e;
font-size: 12px;
}
@media (max-width: 720px) {
.sku-watermark-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 480px) {
.sku-watermark-picker-grid { grid-template-columns: 1fr; }
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Empty, Modal, Spin, Tag } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import type { WatermarkTemplate } from '@atlas/contracts';
import { watermarkPositionLabel } from '@/components/WatermarkPreview';
import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
import './SkuWatermarkPickerModal.css';
type Props = {
open: boolean;
onCancel: () => void;
onApply: (template: WatermarkTemplate) => Promise<void>;
};
/** 选模板只保存模板 ID;短时预览地址只用于弹窗展示。 */
export function SkuWatermarkPickerModal({ open, onCancel, onApply }: Props) {
const { message } = AntdApp.useApp();
const requestVersion = useRef(0);
const [templates, setTemplates] = useState<WatermarkTemplate[]>([]);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [applying, setApplying] = useState(false);
const loadPage = useCallback(async (cursor: string | undefined, version: number) => {
if (cursor) setLoadingMore(true);
else setLoading(true);
try {
const [result, defaultTemplate] = await Promise.all([
watermarksApi.list(cursor),
cursor ? Promise.resolve(null) : watermarksApi.getDefault(),
]);
if (requestVersion.current !== version) return;
const page = defaultTemplate && !result.data.some((template) => template.id === defaultTemplate.id)
? [defaultTemplate, ...result.data] : result.data;
setTemplates((current) => cursor
? [...current, ...page.filter((template) => !current.some((item) => item.id === template.id))]
: page);
if (!cursor) setSelectedId(defaultTemplate?.id ?? null);
setNextCursor(result.meta.has_more ? result.meta.next_cursor : null);
const missing = page.filter((template) => template.kind === 'image'
&& template.config.image_asset_id && !template.config.image_asset?.preview_url);
const previews = await Promise.allSettled(missing.map((template) =>
mediaApi.createPreviewUrl(template.config.image_asset_id!)));
if (requestVersion.current !== version) return;
const urls: Record<string, string> = {};
previews.forEach((preview, index) => {
if (preview.status === 'fulfilled') urls[missing[index].id] = preview.value.url;
});
setPreviewUrls((current) => cursor ? { ...current, ...urls } : urls);
} catch (error) {
if (requestVersion.current === version) message.error(`读取水印模板失败:${apiErrorMessage(error)}`);
} finally {
if (requestVersion.current === version) {
setLoading(false);
setLoadingMore(false);
}
}
}, [message]);
useEffect(() => {
if (!open) return;
const version = ++requestVersion.current;
setTemplates([]);
setPreviewUrls({});
setSelectedId(null);
setNextCursor(null);
void loadPage(undefined, version);
return () => { requestVersion.current++; };
}, [open, loadPage]);
const selected = templates.find((template) => template.id === selectedId);
const apply = async () => {
if (!selected || applying) return;
setApplying(true);
try {
await onApply(selected);
onCancel();
} catch (error) {
message.error(`应用水印失败:${apiErrorMessage(error)}`);
} finally {
setApplying(false);
}
};
return (
<Modal
className="sku-watermark-picker-modal"
open={open}
title="选择水印"
width={820}
onCancel={onCancel}
closable={!applying}
maskClosable={!applying}
keyboard={!applying}
footer={[
<Button key="cancel" onClick={onCancel} disabled={applying}>取消</Button>,
<Button key="apply" type="primary" disabled={!selected} loading={applying} onClick={() => void apply()}>
应用水印
</Button>,
]}
centered
>
<div className="sku-watermark-picker-toolbar">
<Button icon={<AppstoreOutlined />} href="/watermarks" target="_blank" rel="noopener noreferrer">
水印管理
</Button>
</div>
<div className="sku-watermark-picker-scroll">
{loading ? <div className="sku-watermark-picker-loading"><Spin /></div> : templates.length ? (
<div className="sku-watermark-picker-grid">
{templates.map((template) => {
const previewUrl = template.config.image_asset?.preview_url ?? previewUrls[template.id];
return (
<button
key={template.id}
className={`sku-watermark-picker-card${selectedId === template.id ? ' is-selected' : ''}`}
type="button"
aria-pressed={selectedId === template.id}
onClick={() => setSelectedId(template.id)}
>
{template.kind === 'image' && (
previewUrl ? <img src={previewUrl} alt="" />
: <span className="sku-watermark-picker-image-empty">图片</span>
)}
<span className="sku-watermark-picker-card-content">
<span className="sku-watermark-picker-card-heading">
<strong title={template.name}>{template.name}</strong>
<Tag color={template.kind === 'image' ? 'green' : 'blue'}>
{template.kind === 'image' ? '图片' : '文字'}
</Tag>
{template.is_default && <Tag color="gold">默认</Tag>}
</span>
{template.kind === 'text' && (
<span className="sku-watermark-picker-detail" title={template.config.text_content}>
内容:{template.config.text_content}
</span>
)}
<span className="sku-watermark-picker-detail">
{watermarkPositionLabel(template.config.position)}
{template.kind === 'text'
? ` ${template.config.font_code ?? ''}, ${template.config.font_size_px ?? ''}px`
: ''}
</span>
</span>
</button>
);
})}
</div>
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无水印模板,可前往水印管理创建" />}
{nextCursor && (
<div className="sku-watermark-picker-more">
<Button loading={loadingMore} onClick={() => void loadPage(nextCursor, requestVersion.current)}>
加载更多
</Button>
</div>
)}
</div>
</Modal>
);
}
+172 -116
View File
@@ -33,8 +33,11 @@ import type {
WatermarkTemplateConfig,
} from '@atlas/contracts';
import WatermarkPreview, {
WATERMARK_BORDER_RADIUS_MAX_PX,
WATERMARK_IMAGE_SIZE_DEFAULT_PX,
WATERMARK_IMAGE_SIZE_MAX_PX,
WATERMARK_IMAGE_SIZE_MIN_PX,
WATERMARK_POSITION_OPTIONS,
watermarkPositionLabel,
} from '@/components/WatermarkPreview';
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
import { watermarksApi } from '@/services/watermarks';
@@ -45,9 +48,11 @@ import {
WATERMARK_IMAGE_MAX_BYTES,
} from '@/services/media';
type WatermarkPositionValue = (typeof WATERMARK_POSITION_OPTIONS)[number]['value'];
type WatermarkKind = 'text' | 'image';
const DEFAULT_FONT_SIZE_PX = 15;
const DEFAULT_OPACITY_PERCENT = 26;
interface WatermarkFormValues {
name: string;
kind: WatermarkKind;
@@ -55,8 +60,10 @@ interface WatermarkFormValues {
font_code: string;
font_size_px: number;
opacityPercent: number;
scalePercent: number;
position: WatermarkPositionValue;
image_size_px: number;
border_radius_px: number;
position_x: number;
position_y: number;
}
type KindFilter = 'all' | 'text' | 'image';
@@ -71,11 +78,6 @@ const percentToOpacity = (percent: number): string => {
return value === 0 ? '0' : value === 1 ? '1' : String(Number(value.toFixed(4)));
};
const percentToScale = (percent: number): string => {
const value = percent / 100;
return value === 2 ? '2' : String(Number(value.toFixed(4)));
};
const formatDateTime = (value: string): string =>
new Date(value).toLocaleString('zh-CN', { hour12: false });
@@ -91,6 +93,7 @@ const WatermarksPage = () => {
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
const [fonts, setFonts] = useState<Array<{ code: string; display_name: string }>>([]);
@@ -214,10 +217,12 @@ const WatermarksPage = () => {
kind: 'text',
text_content: '',
font_code: fonts[0]?.code ?? '',
font_size_px: 24,
opacityPercent: 80,
scalePercent: 25,
position: 'bottom_right',
font_size_px: DEFAULT_FONT_SIZE_PX,
opacityPercent: DEFAULT_OPACITY_PERCENT,
image_size_px: WATERMARK_IMAGE_SIZE_DEFAULT_PX,
border_radius_px: 0,
position_x: 1,
position_y: 1,
};
form.setFieldsValue(initial);
setPreviewValues(initial);
@@ -227,6 +232,7 @@ const WatermarksPage = () => {
const openEdit = (template: WatermarkTemplate) => {
setEditing(template);
const config = template.config;
const legacyPosition = WATERMARK_POSITION_OPTIONS.find((item) => item.value === config.position);
const assetId = config.image_asset?.id ?? config.image_asset_id ?? null;
setImageAsset(
template.kind === 'image' && assetId
@@ -238,10 +244,12 @@ const WatermarksPage = () => {
kind: template.kind,
text_content: config.text_content ?? '',
font_code: config.font_code ?? fonts[0]?.code ?? '',
font_size_px: config.font_size_px ?? 24,
opacityPercent: Math.round(Number(config.opacity ?? '0.8') * 100),
scalePercent: Math.round(Number(config.scale_ratio ?? '0.25') * 100),
position: config.position,
font_size_px: config.font_size_px ?? DEFAULT_FONT_SIZE_PX,
opacityPercent: Math.round(Number(config.opacity ?? '0.26') * 100),
image_size_px: config.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX,
border_radius_px: config.border_radius_px ?? 0,
position_x: config.position_x ?? (legacyPosition ? legacyPosition.col / 2 : 1),
position_y: config.position_y ?? (legacyPosition ? legacyPosition.row / 2 : 1),
};
form.setFieldsValue(initial);
setPreviewValues(initial);
@@ -319,8 +327,9 @@ const WatermarksPage = () => {
setSaving(true);
try {
// 滑杆字段在类型切换后可能缺值,统一兜底默认值,避免 NaN
const opacity = percentToOpacity(values.opacityPercent ?? 80);
const scaleRatio = percentToScale(values.scalePercent ?? 25);
const opacity = percentToOpacity(values.opacityPercent ?? DEFAULT_OPACITY_PERCENT);
const imageSize = values.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX;
const borderRadius = values.border_radius_px ?? 0;
// 编辑时只提交有变化的字段:渲染参数未变时仅改名,
// 服务端不会创建新的渲染版本(docs/api/watermarks-and-assets.md §3.3);
// 切换类型必须提交新类型的完整 config。
@@ -335,14 +344,17 @@ const WatermarksPage = () => {
font_code: values.font_code,
font_size_px: values.font_size_px,
opacity,
position: values.position,
position_x: values.position_x,
position_y: values.position_y,
};
} else {
config = {
image_asset_id: imageAsset?.id ?? '',
scale_ratio: scaleRatio,
image_size_px: imageSize,
border_radius_px: borderRadius,
opacity,
position: values.position,
position_x: values.position_x,
position_y: values.position_y,
};
}
payload = { kind: values.kind, config };
@@ -368,9 +380,8 @@ const WatermarksPage = () => {
if (Number(opacity) !== Number(current.opacity)) {
configPatch.opacity = opacity;
}
if (values.position !== current.position) {
configPatch.position = values.position;
}
if (values.position_x !== current.position_x) configPatch.position_x = values.position_x;
if (values.position_y !== current.position_y) configPatch.position_y = values.position_y;
if (Object.keys(configPatch).length > 0) {
diff.config = configPatch;
}
@@ -386,15 +397,13 @@ const WatermarksPage = () => {
if (imageAsset && imageAsset.id !== currentAssetId) {
configPatch.image_asset_id = imageAsset.id;
}
if (percentToScale(values.scalePercent) !== current.scale_ratio) {
configPatch.scale_ratio = scaleRatio;
}
if (imageSize !== current.image_size_px) configPatch.image_size_px = imageSize;
if (borderRadius !== current.border_radius_px) configPatch.border_radius_px = borderRadius;
if (Number(opacity) !== Number(current.opacity)) {
configPatch.opacity = opacity;
}
if (values.position !== current.position) {
configPatch.position = values.position;
}
if (values.position_x !== current.position_x) configPatch.position_x = values.position_x;
if (values.position_y !== current.position_y) configPatch.position_y = values.position_y;
if (Object.keys(configPatch).length > 0) {
diff.config = configPatch;
}
@@ -419,7 +428,8 @@ const WatermarksPage = () => {
font_code: values.font_code,
font_size_px: values.font_size_px,
opacity,
position: values.position,
position_x: values.position_x,
position_y: values.position_y,
},
});
applyTemplate(template);
@@ -430,9 +440,11 @@ const WatermarksPage = () => {
kind: 'image',
config: {
image_asset_id: imageAsset?.id ?? '',
scale_ratio: scaleRatio,
image_size_px: imageSize,
border_radius_px: borderRadius,
opacity,
position: values.position,
position_x: values.position_x,
position_y: values.position_y,
},
});
applyTemplate(template);
@@ -451,8 +463,8 @@ const WatermarksPage = () => {
'font_code',
'font_size_px',
'opacityPercent',
'scalePercent',
'position',
'image_size_px',
'border_radius_px',
];
if (details.length > 0) {
const fieldErrors = [];
@@ -550,6 +562,21 @@ const WatermarksPage = () => {
});
};
const setDefaultTemplate = async (templateId: string) => {
setSettingDefaultId(templateId);
try {
const nextDefault = await watermarksApi.setDefault(templateId);
setTemplates((current) => current.map((template) => template.id === templateId
? nextDefault
: template.is_default ? { ...template, is_default: false } : template));
message.success('已设为默认水印');
} catch (error) {
message.error(`设置默认水印失败:${apiErrorMessage(error)}`);
} finally {
setSettingDefaultId(null);
}
};
const columns: TableProps<WatermarkTemplate>['columns'] = [
{
title: '序号',
@@ -562,6 +589,12 @@ const WatermarksPage = () => {
dataIndex: 'name',
width: 220,
ellipsis: true,
render: (name: string, template) => (
<Space size={4}>
<span>{name}</span>
{template.is_default && <Tag color="gold">默认</Tag>}
</Space>
),
},
{
title: '水印类型',
@@ -605,8 +638,8 @@ const WatermarksPage = () => {
{
title: '水印位置',
key: 'position',
width: 110,
render: (_, template) => watermarkPositionLabel(template.config.position),
width: 160,
render: (_, template) => `横 ${Math.round(template.config.position_x * 100)}% · 纵 ${Math.round(template.config.position_y * 100)}%`,
},
{
title: '版本',
@@ -625,9 +658,18 @@ const WatermarksPage = () => {
{
title: '操作',
key: 'actions',
width: 190,
width: 320,
render: (_, template) => (
<Space size={4}>
<Button
type="link"
size="small"
disabled={template.is_default || settingDefaultId !== null}
loading={settingDefaultId === template.id}
onClick={() => void setDefaultTemplate(template.id)}
>
{template.is_default ? '默认水印' : '设为默认水印'}
</Button>
<Button type="link" size="small" onClick={() => void openPreview(template)}>
查看效果
</Button>
@@ -647,6 +689,11 @@ const WatermarksPage = () => {
},
];
const handlePreviewPositionChange = (position: { position_x: number; position_y: number }) => {
form.setFieldsValue(position);
setPreviewValues((current) => current ? { ...current, ...position } : current);
};
const previewConfig = useMemo<Partial<WatermarkTemplateConfig>>(() => {
if (!previewValues) return {};
if (previewValues.kind === 'image') {
@@ -656,9 +703,11 @@ const WatermarksPage = () => {
preview_url: imageAsset?.previewUrl ?? '',
preview_url_expires_at: '',
},
scale_ratio: percentToScale(previewValues.scalePercent),
image_size_px: previewValues.image_size_px,
border_radius_px: previewValues.border_radius_px,
opacity: percentToOpacity(previewValues.opacityPercent),
position: previewValues.position,
position_x: previewValues.position_x,
position_y: previewValues.position_y,
};
}
return {
@@ -666,7 +715,8 @@ const WatermarksPage = () => {
font_code: previewValues.font_code,
font_size_px: previewValues.font_size_px,
opacity: percentToOpacity(previewValues.opacityPercent),
position: previewValues.position,
position_x: previewValues.position_x,
position_y: previewValues.position_y,
};
}, [previewValues, imageAsset]);
@@ -788,10 +838,22 @@ const WatermarksPage = () => {
>
<Form<WatermarkFormValues>
form={form}
layout="vertical"
className="watermark-editor-form"
layout="horizontal"
labelCol={{ flex: '88px' }}
wrapperCol={{ flex: 'auto' }}
onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)}
onFinish={handleSave}
>
<Form.Item name="kind" hidden>
<Input />
</Form.Item>
<Form.Item name="position_x" hidden>
<Input />
</Form.Item>
<Form.Item name="position_y" hidden>
<Input />
</Form.Item>
<Form.Item
name="name"
label="模板名称"
@@ -850,15 +912,24 @@ const WatermarksPage = () => {
5MB
</Typography.Paragraph>
<Form.Item
name="scalePercent"
label="图片缩放"
preserve={false}
name="image_size_px"
label="图片大小"
extra={`预览最长边 ${previewValues?.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX}px;应用到商品图时等比换算`}
>
<Slider
min={10}
max={200}
min={WATERMARK_IMAGE_SIZE_MIN_PX}
max={WATERMARK_IMAGE_SIZE_MAX_PX}
step={1}
tooltip={{ formatter: (value) => `${value ?? 0}%` }}
tooltip={{ formatter: (value) => `${value ?? 0}px` }}
/>
</Form.Item>
<Form.Item name="border_radius_px" label="图片圆角">
<InputNumber
min={0}
max={WATERMARK_BORDER_RADIUS_MAX_PX}
precision={0}
addonAfter="px"
style={{ width: 150 }}
/>
</Form.Item>
</>
@@ -878,34 +949,40 @@ const WatermarksPage = () => {
>
<Input maxLength={200} placeholder="例如:Atlas Store" />
</Form.Item>
<Form.Item
name="font_code"
label="字体"
required
preserve={false}
>
<Select
placeholder="选择字体"
notFoundContent="渲染服务暂无可用字体"
options={fonts.map((font) => ({
value: font.code,
label: font.display_name,
}))}
/>
</Form.Item>
<Form.Item
name="font_size_px"
label="字号(px)"
required
rules={[{ required: true, message: '请输入字号' }]}
>
<InputNumber
min={12}
max={60}
precision={0}
style={{ width: 140 }}
/>
</Form.Item>
<div className="watermark-editor-pair">
<div>
<Form.Item
name="font_code"
label="字体"
required
>
<Select
placeholder="选择字体"
notFoundContent="渲染服务暂无可用字体"
options={fonts.map((font) => ({
value: font.code,
label: font.display_name,
}))}
/>
</Form.Item>
</div>
<div>
<Form.Item
name="font_size_px"
label="字号(px)"
labelCol={{ flex: '136px' }}
required
rules={[{ required: true, message: '请输入字号' }]}
>
<InputNumber
min={12}
max={60}
precision={0}
style={{ width: 96 }}
/>
</Form.Item>
</div>
</div>
</>
)
}
@@ -920,14 +997,14 @@ const WatermarksPage = () => {
/>
</Form.Item>
<Form.Item label="水印位置" required>
<Form.Item name="position" noStyle>
<PositionGrid />
</Form.Item>
</Form.Item>
<Form.Item label="预览效果">
<WatermarkPreview config={previewConfig} />
<Form.Item
label="预览效果"
extra="拖动水印可放在任意位置;方向键可微调"
>
<WatermarkPreview
config={previewConfig}
onPositionChange={handlePreviewPositionChange}
/>
</Form.Item>
<div className="watermark-form-actions">
@@ -943,11 +1020,19 @@ const WatermarksPage = () => {
title={previewing ? `查看效果:${previewing.name}` : '查看效果'}
open={!!previewing}
onCancel={() => setPreviewing(null)}
footer={null}
footer={
<div className="watermark-preview-dialog-footer">
<Button type="primary" onClick={() => setPreviewing(null)}>关闭</Button>
</div>
}
width={420}
destroyOnHidden
>
{previewing && <WatermarkPreview config={previewing.config} />}
{previewing && (
<div className="watermark-preview-dialog-content">
<WatermarkPreview config={previewing.config} />
</div>
)}
</Modal>
</div>
);
@@ -958,33 +1043,4 @@ const dayjsOrNull = (value: string) => {
return parsed.isValid() ? parsed : null;
};
/** 3x3 水印位置九宫格选择器:选中格高亮,旁边显示位置名称 */
const PositionGrid = ({
value,
onChange,
}: {
value?: WatermarkPositionValue;
onChange?: (value: WatermarkPositionValue) => void;
}) => {
const selected = WATERMARK_POSITION_OPTIONS.find((option) => option.value === value);
return (
<Space size={12} align="center">
<div className="position-grid">
{WATERMARK_POSITION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`position-grid-cell${option.value === value ? ' active' : ''}`}
aria-label={option.label}
onClick={() => onChange?.(option.value)}
/>
))}
</div>
<Typography.Text type="secondary">{positionGridLabel(selected?.label)}</Typography.Text>
</Space>
);
};
const positionGridLabel = (label?: string) => label ?? '请选择位置';
export default WatermarksPage;
+105
View File
@@ -6,6 +6,12 @@ import type {
ProductUploadImage,
ProductImportedImage,
ImportProductImageRequest,
AspectRatioDerivative,
WatermarkDerivative,
CreateMediaAiEditRequest,
MediaAiEdit,
ProductImageTask,
ProductImageDerivativeRecord,
} from '@atlas/contracts';
import { api } from '@/services/api';
@@ -15,6 +21,32 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
export const mediaApi = {
createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) =>
api.post<MediaAiEdit>(`/media-assets/${assetId}/ai-edits`, payload,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readAiEdit: (id: string) => api.get<MediaAiEdit>(`/media-ai-edits/${id}`),
listAiEdits: (productId: string) => api.get<MediaAiEdit[]>(`/media-ai-edits?product_id=${encodeURIComponent(productId)}`),
listProductImageDerivatives: (productId: string) =>
api.get<ProductImageDerivativeRecord[]>(`/products/${encodeURIComponent(productId)}/image-derivatives`),
listProductImageTasks: (productId: string) =>
api.getList<ProductImageTask>(`/products/${encodeURIComponent(productId)}/image-tasks?limit=100`)
.then((response) => response.data),
cancelAiEdit: (id: string) => api.post<{ id: string; status: MediaAiEdit['status'] }>(
`/media-ai-edits/${id}/cancel`, {}, { headers: { 'Idempotency-Key': crypto.randomUUID() } }),
createThreeFour: (assetId: string, productId: string) =>
api.post<AspectRatioDerivative>(
`/media-assets/${assetId}/aspect-ratio-derivatives`,
{ preset: '3:4', fit: 'contain', product_id: productId },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
readDerivative: (derivativeId: string) =>
api.get<AspectRatioDerivative>(`/media-derivatives/${derivativeId}`),
createWatermarkDerivative: (assetId: string, templateId: string, productId: string) =>
api.post<WatermarkDerivative>(`/media-assets/${assetId}/watermark-derivatives`,
{ watermark_template_id: templateId, product_id: productId },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readWatermarkDerivative: (derivativeId: string) =>
api.get<WatermarkDerivative>(`/media-watermark-derivatives/${derivativeId}`),
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
importProductImage: (payload: ImportProductImageRequest) =>
@@ -40,8 +72,81 @@ export const mediaApi = {
{ purpose: 'preview' },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
createDownloadUrl: (assetId: string) =>
api.post<MediaAccessUrl>(
`/media-assets/${assetId}/access-urls`,
{ purpose: 'download' },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
};
/** 图片任务提交后通知商品页刷新右下角任务列表。 */
export function notifyProductImageTaskSubmitted(productId: string): void {
window.dispatchEvent(new CustomEvent('atlas:product-image-task-submitted', { detail: { productId } }));
}
/** 图片编辑错误码对应可操作的提示,不向前端透传供应商原始响应。 */
export function mediaAiEditFailureMessage(code: string | null): string {
if (code === 'AI_IMAGE_CONTENT_REJECTED')
return 'Pcore 未能完成这次生图;可能是输入或生成结果被审核拦截。可尝试其他图片或调整编辑要求。';
if (code === 'AI_IMAGE_PROVIDER_FAILED')
return 'Pcore 图片任务失败,请稍后重试。';
return '图片生成失败,请重试。';
}
const aiEditFailureStageLabels = {
task_setup: '任务初始化', input_preparation: '读取与准备原图',
provider_submission: '提交 Pcore 生图请求', provider_polling: '查询 Pcore 生图结果',
result_download: '获取生成图片', result_processing: 'Atlas 处理生成图片',
result_storage: 'Atlas 保存生成图片',
} as const;
export function mediaAiEditFailureDiagnosis(failure: {
failure_source: 'pcore' | 'atlas' | null;
failure_stage: keyof typeof aiEditFailureStageLabels | null;
failure_detail: string | null;
}): string {
if (!failure.failure_source && !failure.failure_stage) return '失败阶段未记录(历史任务)';
const source = failure.failure_source === 'pcore' ? 'Pcore 接口' : 'Atlas 自身处理';
const stage = failure.failure_stage ? aiEditFailureStageLabels[failure.failure_stage] : '未知步骤';
return `失败来源:${source} · 失败步骤:${stage}${failure.failure_detail ? ` · 详情:${failure.failure_detail}` : ''}`;
}
/** 下载地址由对象存储返回 attachment 响应头,跨域图片也会保存为文件。 */
export async function downloadMediaAsset(assetId: string): Promise<void> {
const access = await mediaApi.createDownloadUrl(assetId);
const link = document.createElement('a');
link.href = access.url;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}
/** 派生任务可能跨越多次 Worker 重试;页面只使用成功返回的正式素材 ID。 */
export async function createThreeFourImage(assetId: string, productId: string): Promise<AspectRatioDerivative> {
const created = await mediaApi.createThreeFour(assetId, productId);
for (let attempt = 0; attempt < 120; attempt += 1) {
const current = await mediaApi.readDerivative(created.id);
if (current.status === 'succeeded' && current.output_asset) return current;
if (current.status === 'failed') throw new Error('图片处理失败,请重试');
await new Promise<void>((resolve) => window.setTimeout(resolve, 1_500));
}
throw new Error('图片仍在处理中,请稍后重试');
}
/** 应用水印创建独立素材,调用方在选图弹窗中替换预览。 */
export async function createWatermarkedImage(assetId: string, templateId: string, productId: string): Promise<WatermarkDerivative> {
const created = await mediaApi.createWatermarkDerivative(assetId, templateId, productId);
for (let attempt = 0; attempt < 120; attempt += 1) {
const current = await mediaApi.readWatermarkDerivative(created.id);
if (current.status === 'succeeded' && current.output_asset) return current;
if (current.status === 'failed') throw new Error('水印处理失败,请重试');
await new Promise<void>((resolve) => window.setTimeout(resolve, 1_500));
}
throw new Error('水印仍在处理中,请稍后重试');
}
/** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */
export async function sha256Hex(file: File): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
+59
View File
@@ -0,0 +1,59 @@
import type {
CursorPaginationMeta,
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
} from '@atlas/contracts';
import { api } from './api';
export type OnlineProductFilters = {
q: string;
visibility: 'all' | 'visible' | 'invisible' | 'unknown';
source?: 'atlas_published' | 'external_sync';
moderation_status?: string;
};
export const onlineProductsApi = {
list: (platformId: string, storeId: string, filters: OnlineProductFilters, cursor?: string) =>
api.getList<OnlineProduct, OnlineProductsMeta>('/platform-listings', {
headers: { 'X-Atlas-Platform-Id': platformId },
params: {
store_id: storeId,
limit: 20,
...(cursor ? { cursor } : {}),
...(filters.q.trim() ? { q: filters.q.trim() } : {}),
visibility: filters.visibility,
...(filters.source ? { source: filters.source } : {}),
...(filters.moderation_status
? { moderation_status: filters.moderation_status }
: {}),
},
}),
get: (platformId: string, listingId: string) =>
api.get<OnlineProduct>(`/platform-listings/${listingId}`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
createSync: (platformId: string, storeId: string) =>
api.post<{ sync_run: OnlineProductSyncRun }>(
`/stores/${storeId}/platform-product-sync-runs`,
{ reason: 'manual_refresh' },
{
headers: {
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': crypto.randomUUID(),
},
}
),
getSyncRun: (platformId: string, runId: string) =>
api.get<OnlineProductSyncRun>(`/platform-product-sync-runs/${runId}`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
listSyncRuns: (platformId: string, storeId: string, cursor?: string) =>
api.getList<OnlineProductSyncRun, CursorPaginationMeta>(
`/stores/${storeId}/platform-product-sync-runs`,
{
headers: { 'X-Atlas-Platform-Id': platformId },
params: { limit: 20, ...(cursor ? { cursor } : {}) },
}
),
};
+7
View File
@@ -32,6 +32,13 @@ export const watermarksApi = {
get: (templateId: string) => api.get<WatermarkTemplate>(`/watermark-templates/${templateId}`),
getDefault: () => api.get<WatermarkTemplate | null>('/watermark-templates/default'),
setDefault: (templateId: string) =>
api.put<WatermarkTemplate>(`/watermark-templates/${templateId}/default`, {}, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
create: (payload: CreateWatermarkTemplateRequest) =>
api.post<WatermarkTemplate>('/watermark-templates', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },