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