feat(ai-image): AI生图开发
This commit is contained in:
+46
-27
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 } : {}) },
|
||||
}
|
||||
),
|
||||
};
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user