feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -551,12 +551,12 @@ export function PricingCalculator({
|
||||
}
|
||||
options={[
|
||||
{ value: 'target_markup', label: '按目标算价' },
|
||||
{ value: 'fixed_sale', label: '手动售价' },
|
||||
{ value: 'fixed_sale', label: '手动实际售价' },
|
||||
]}
|
||||
/>
|
||||
{input.pricing.mode === 'fixed_sale' && (
|
||||
<InputNumber<string>
|
||||
aria-label="手动售价"
|
||||
aria-label="手动实际售价"
|
||||
stringMode
|
||||
min="0.01"
|
||||
value={input.pricing.selling_price}
|
||||
@@ -566,7 +566,7 @@ export function PricingCalculator({
|
||||
pricing: { ...input.pricing, selling_price: v },
|
||||
})
|
||||
}
|
||||
placeholder={`销售价 ${catalog.store.currency_code}`}
|
||||
placeholder={`实际售价 ${catalog.store.currency_code}`}
|
||||
/>
|
||||
)}
|
||||
<PricingParametersFields
|
||||
@@ -582,10 +582,10 @@ export function PricingCalculator({
|
||||
{previous && (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`上次报价:${previous.selling_price} ${previous.currency_code} · 预估净利润 ¥${previous.profit_cny}`}
|
||||
title={`上次实际售价:${previous.selling_price} ${previous.currency_code} · 预估净利润 ¥${previous.profit_cny}`}
|
||||
description={
|
||||
option
|
||||
? `本次报价:${option.selling_price} ${option.currency_code} · 预估净利润 ¥${option.profit_cny}。点击应用才接受本次结果。`
|
||||
? `本次实际售价:${option.selling_price} ${option.currency_code} · 预估净利润 ¥${option.profit_cny}。点击应用才接受本次结果。`
|
||||
: '重新试算后可对比价格与利润。'
|
||||
}
|
||||
/>
|
||||
@@ -618,18 +618,22 @@ export function PricingCalculator({
|
||||
{option ? (
|
||||
<>
|
||||
<Card style={{ background: '#f7f5ff' }}>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Row gutter={[12, 16]}>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title={`销售价 · ${option.currency_code}`}
|
||||
title={`实际售价 · ${option.currency_code}`}
|
||||
value={option.selling_price}
|
||||
precision={2}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
划线价 {option.list_price}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title={`划线价 · ${option.currency_code}`}
|
||||
value={option.list_price}
|
||||
precision={2}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic
|
||||
title="预估净利润 · CNY"
|
||||
value={option.profit_cny}
|
||||
@@ -649,6 +653,12 @@ export function PricingCalculator({
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
style={{ marginTop: 12, marginBottom: 0 }}
|
||||
>
|
||||
首次上架按划线价提交,预估净利润按实际售价计算。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
{option.commission_details && (
|
||||
<Alert type="info" title={`平台佣金 ${rateToPercent(option.commission_rate)}% · ${option.commission_details.category_name}`} description={
|
||||
|
||||
@@ -78,9 +78,9 @@ export function SkuPricing({
|
||||
onChanged: () => void;
|
||||
children: (slots: PricingSlots) => ReactNode;
|
||||
}) {
|
||||
Form.useWatch('variants', form);
|
||||
Form.useWatch('common', form);
|
||||
Form.useWatch('profile', form);
|
||||
Form.useWatch('variants', { form, preserve: true });
|
||||
Form.useWatch('common', { form, preserve: true });
|
||||
Form.useWatch('profile', { form, preserve: true });
|
||||
const values = form.getFieldsValue(true) as Values,
|
||||
v = values.variants[index],
|
||||
original = snapshot.variants.find((x) => x.id === v?.id);
|
||||
@@ -128,8 +128,6 @@ export function SkuPricing({
|
||||
: null;
|
||||
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
|
||||
const liveKey = live ? keyFor(live) : '';
|
||||
const liveRef = useRef(liveKey);
|
||||
liveRef.current = liveKey;
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
@@ -212,14 +210,19 @@ export function SkuPricing({
|
||||
setError('');
|
||||
onChanged();
|
||||
}
|
||||
async function calculate(force = false, override?: PricingParameters) {
|
||||
if (!catalog || !v || original?.listing.status === 'imported') return;
|
||||
async function calculate(
|
||||
force = false,
|
||||
override?: PricingParameters,
|
||||
catalogOverride?: PricingCatalog
|
||||
) {
|
||||
const sourceCatalog = catalogOverride ?? catalog;
|
||||
if (!sourceCatalog || !v || original?.listing.status === 'imported') return;
|
||||
if (!fulfillmentSupported) {
|
||||
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
|
||||
return;
|
||||
}
|
||||
const current = form.getFieldValue(['variants', index]) as Values['variants'][number];
|
||||
const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog);
|
||||
const parameters = override ?? current.pricing_draft ?? defaultPricing(sourceCatalog);
|
||||
if (!parameters) {
|
||||
setError('没有可用的物流费率,请检查费率卡和草稿卡使用配置');
|
||||
return;
|
||||
@@ -251,7 +254,16 @@ export function SkuPricing({
|
||||
setError('');
|
||||
try {
|
||||
const p = await pricingApi.preview(parsed.data, controller.current.signal);
|
||||
if (seq !== sequence.current || liveRef.current !== inputKey) return;
|
||||
if (seq !== sequence.current) return;
|
||||
// 直接核对表单,避免方案回填后的 React 渲染时序误丢弃新报价。
|
||||
const latest = form.getFieldsValue(true) as Values;
|
||||
const latestParameters = latest.variants[index]?.pricing_draft;
|
||||
if (
|
||||
!latestParameters ||
|
||||
keyFor(skuPricingInput(latest, index, snapshot.profile.id, latestParameters)) !==
|
||||
inputKey
|
||||
)
|
||||
return;
|
||||
const recommended = p.options.find((o) => o.id === p.recommended_option_id);
|
||||
if (!recommended) {
|
||||
setError(
|
||||
@@ -279,6 +291,42 @@ export function SkuPricing({
|
||||
if (seq === sequence.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
async function calculateFromDefault() {
|
||||
if (!v || original?.listing.status === 'imported') return;
|
||||
if (!fulfillmentSupported) {
|
||||
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
|
||||
return;
|
||||
}
|
||||
const requestedStore = form.getFieldValue(['profile', 'target_store_id']);
|
||||
controller.current?.abort();
|
||||
const seq = ++sequence.current;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
// 主动按方案计算必须重读默认方案,不能沿用旧 SKU 快照或页面加载时的缓存。
|
||||
const fresh = await pricingApi.catalog(requestedStore);
|
||||
if (
|
||||
seq !== sequence.current ||
|
||||
form.getFieldValue(['profile', 'target_store_id']) !== requestedStore
|
||||
)
|
||||
return;
|
||||
setCatalog(fresh);
|
||||
const policy = fresh.policies.find((item) => item.is_default);
|
||||
if (!policy) {
|
||||
setError('请先为目标店铺设置默认定价方案,再按方案计算');
|
||||
return;
|
||||
}
|
||||
if (!fresh.rate_cards.some((card) => card.id === policy.parameters.rate_card_id)) {
|
||||
setError('默认定价方案的物流费率卡不可用,请检查店铺定价方案');
|
||||
return;
|
||||
}
|
||||
await calculate(true, structuredClone(policy.parameters), fresh);
|
||||
} catch (e) {
|
||||
if (seq === sequence.current) setError(apiErrorMessage(e));
|
||||
} finally {
|
||||
if (seq === sequence.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
// 包装和门店上下文变化自动重算;采购价与手动售价仅在失焦/Enter 时提交。
|
||||
const contextKey = live
|
||||
? JSON.stringify([
|
||||
@@ -292,6 +340,8 @@ export function SkuPricing({
|
||||
useEffect(() => {
|
||||
if (previousContext.current === contextKey) return;
|
||||
previousContext.current = contextKey;
|
||||
// 首次取得方案建立计算上下文时,同输入的主动试算已经在进行,无需取消重发。
|
||||
if (lastRequest.current === liveKey) return;
|
||||
sequence.current++;
|
||||
controller.current?.abort();
|
||||
setBusy(false);
|
||||
@@ -303,7 +353,7 @@ export function SkuPricing({
|
||||
useEffect(() => {
|
||||
if (batch === lastBatch.current) return;
|
||||
lastBatch.current = batch;
|
||||
void calculate(true);
|
||||
void calculateFromDefault();
|
||||
}, [batch]);
|
||||
if (!v) return null;
|
||||
const imported = original?.listing.status === 'imported';
|
||||
@@ -314,6 +364,21 @@ export function SkuPricing({
|
||||
: (option?.merchandise_value_cny ??
|
||||
original?.listing.pricing_result?.merchandise_value_cny);
|
||||
const profitCny = option?.profit_cny;
|
||||
const displayedQuote = option ?? original?.listing.pricing_result;
|
||||
// 两价使用同一报价的换算比例,避免历史报价被当前汇率重新换算。
|
||||
const listCny = displayedQuote
|
||||
? displayedQuote.currency_code === 'CNY'
|
||||
? displayedQuote.list_price
|
||||
: Number(displayedQuote.selling_price) > 0
|
||||
? String(
|
||||
(Number(displayedQuote.list_price) *
|
||||
Number(displayedQuote.merchandise_value_cny)) /
|
||||
Number(displayedQuote.selling_price)
|
||||
)
|
||||
: null
|
||||
: currency === 'CNY'
|
||||
? original?.listing.list_price
|
||||
: null;
|
||||
const statusText = busy
|
||||
? '计算中'
|
||||
: error || !fulfillmentSupported
|
||||
@@ -361,9 +426,13 @@ export function SkuPricing({
|
||||
<strong>{cny(v.purchase_cost_cny)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>售价</span>
|
||||
<span>实际售价</span>
|
||||
<strong>{cny(saleCny)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>划线价</span>
|
||||
<strong>{cny(listCny)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预估净利润</span>
|
||||
<strong
|
||||
@@ -389,7 +458,7 @@ export function SkuPricing({
|
||||
<Button
|
||||
loading={busy}
|
||||
type="primary"
|
||||
onClick={() => void calculate(true)}
|
||||
onClick={() => void calculateFromDefault()}
|
||||
>
|
||||
重新试算
|
||||
</Button>
|
||||
@@ -438,10 +507,10 @@ export function SkuPricing({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>售价(人民币)</span>
|
||||
<span>实际售价(人民币)</span>
|
||||
{currency === 'CNY' ? (
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 售价(人民币)`}
|
||||
aria-label={`SKU ${index + 1} 实际售价(人民币)`}
|
||||
min="0.01"
|
||||
stringMode
|
||||
precision={2}
|
||||
@@ -472,6 +541,10 @@ export function SkuPricing({
|
||||
<div className="sku-pricing-readonly">{cny(saleCny)}</div>
|
||||
)}
|
||||
</label>
|
||||
<div>
|
||||
<span>划线价(人民币)</span>
|
||||
<div className="sku-pricing-readonly">{cny(listCny)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>预估净利润(人民币)</span>
|
||||
<div
|
||||
|
||||
@@ -38,6 +38,8 @@ import { ProductMainInfoSection } from './components/ProductMainInfoSection';
|
||||
import { ProductOtherSection } from './components/ProductOtherSection';
|
||||
import { ProductVariantSection } from './components/ProductVariantSection';
|
||||
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
|
||||
import { ProductImageSuiteDrawer } from './components/ProductImageSuiteDrawer';
|
||||
import type { SkuEditorImage } from './components/SkuImageCard';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
@@ -460,8 +462,10 @@ const ProductEditPage = () => {
|
||||
// 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。
|
||||
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
|
||||
const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form);
|
||||
const attributeDrafts = Form.useWatch('attributes', { form, preserve: true });
|
||||
// 编辑器快照与采集来源:sourceDraft 是用户可能改写过的采集素材副本。
|
||||
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
||||
const attributeProfileId = snapshot?.profile.id;
|
||||
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
||||
const [sourceDraft, setSourceDraft] = useState<ProductAiSourceMaterial | null>(null);
|
||||
const [sourceOverriddenFields, setSourceOverriddenFields] = useState<ProductAiSourceField[]>(
|
||||
@@ -474,9 +478,6 @@ const ProductEditPage = () => {
|
||||
// 类目属性 schema 与字典值:选定类目后加载,必填属性始终展示。
|
||||
const [attributeSchema, setAttributeSchema] = useState<ProductAttributeSchema[]>([]);
|
||||
const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState<string | null>(null);
|
||||
const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [attributeSchemaLoading, setAttributeSchemaLoading] = useState(false);
|
||||
const [attributeSchemaError, setAttributeSchemaError] = useState<string | null>(null);
|
||||
const [dictionaryValues, setDictionaryValues] = useState<
|
||||
@@ -497,6 +498,8 @@ const ProductEditPage = () => {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [publishStatusContainer, setPublishStatusContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
|
||||
const [suiteOpen, setSuiteOpen] = useState(false);
|
||||
const [suiteImages, setSuiteImages] = useState<Record<string, SkuEditorImage[]>>({});
|
||||
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
|
||||
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
|
||||
const [usesVariantTags, setUsesVariantTags] = useState(false);
|
||||
@@ -539,6 +542,13 @@ const ProductEditPage = () => {
|
||||
const brandDefinition = activeAttributeSchema.find(
|
||||
(attribute) => attribute.editor_field === 'brand'
|
||||
);
|
||||
// 保存重载或清空属性草稿时仍需补齐控件,不能依赖同类目 schema 再次请求。
|
||||
useEffect(() => {
|
||||
if (!snapshot || !activeAttributeSchema.length) return;
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
const missing = missingAttributeDrafts(attributes, activeAttributeSchema, snapshot);
|
||||
if (missing.length) form.setFieldValue('attributes', [...attributes, ...missing]);
|
||||
}, [activeAttributeSchema, attributeDrafts, form, snapshot]);
|
||||
|
||||
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
|
||||
const load = useCallback(async () => {
|
||||
@@ -603,21 +613,15 @@ const ProductEditPage = () => {
|
||||
}, [selectedCategoryId, snapshot?.profile.id]);
|
||||
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
||||
if (!attributeProfileId || !currentPlatform || !selectedCategoryId) {
|
||||
setAttributeSchema([]);
|
||||
setAttributeSchemaCategoryId(null);
|
||||
setAttributeSchemaCatalogRefresh(null);
|
||||
setAttributeSchemaLoading(false);
|
||||
setAttributeSchemaError(null);
|
||||
setDictionaryValues({});
|
||||
setDictionaryCursors({});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
attributeSchemaCategoryId === selectedCategoryId &&
|
||||
attributeSchemaCatalogRefresh === catalogRefresh
|
||||
)
|
||||
return;
|
||||
let active = true;
|
||||
setAttributeSchemaLoading(true);
|
||||
setAttributeSchemaError(null);
|
||||
@@ -626,19 +630,12 @@ const ProductEditPage = () => {
|
||||
setDictionaryValues({});
|
||||
setDictionaryCursors({});
|
||||
void productEditorApi
|
||||
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId)
|
||||
.attributeSchema(attributeProfileId, currentPlatform.id, selectedCategoryId)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
const schema = result.items;
|
||||
const currentAttributes = (form.getFieldValue('attributes') ??
|
||||
[]) as AttributeDraft[];
|
||||
const missing = missingAttributeDrafts(currentAttributes, schema, snapshot);
|
||||
if (missing.length) {
|
||||
form.setFieldValue('attributes', [...currentAttributes, ...missing]);
|
||||
}
|
||||
setAttributeSchema(schema);
|
||||
setAttributeSchemaCategoryId(selectedCategoryId);
|
||||
setAttributeSchemaCatalogRefresh(catalogRefresh);
|
||||
setAttributeSchemaLoading(false);
|
||||
if (result.cache.status === 'expired' || result.cache.status === 'missing') {
|
||||
schemaPollAttempts.current += 1;
|
||||
@@ -657,7 +654,6 @@ const ProductEditPage = () => {
|
||||
if (active) {
|
||||
setAttributeSchema([]);
|
||||
setAttributeSchemaCategoryId(null);
|
||||
setAttributeSchemaCatalogRefresh(null);
|
||||
setAttributeSchemaLoading(false);
|
||||
setAttributeSchemaError(apiErrorMessage(error));
|
||||
}
|
||||
@@ -666,13 +662,10 @@ const ProductEditPage = () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
attributeSchemaCategoryId,
|
||||
attributeSchemaCatalogRefresh,
|
||||
catalogRefresh,
|
||||
currentPlatform,
|
||||
form,
|
||||
selectedCategoryId,
|
||||
snapshot,
|
||||
attributeProfileId,
|
||||
]);
|
||||
// 预取类目属性字典;品牌初始只查询 Ozon 的无品牌候选,其他品牌按关键词加载。
|
||||
useEffect(() => {
|
||||
@@ -1093,7 +1086,10 @@ const ProductEditPage = () => {
|
||||
setAiAutofillLoading(true);
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
const formAtStart = JSON.stringify(form.getFieldsValue(true));
|
||||
// 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。
|
||||
const formAtStart = JSON.stringify(
|
||||
normalizedValues(form.getFieldsValue(true) as EditorFormValues)
|
||||
);
|
||||
setAiAutofillStatus('正在分析采集信息…');
|
||||
const task = await productEditorApi.aiAutofill(snapshot.profile.id, currentPlatform.id);
|
||||
const completed = await waitForAiTask(task.ai_task_id);
|
||||
@@ -1105,7 +1101,7 @@ const ProductEditPage = () => {
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
latest.source_material.source_snapshot_revision !==
|
||||
completed.input_revision.source_snapshot ||
|
||||
JSON.stringify(form.getFieldsValue(true)) !== formAtStart
|
||||
JSON.stringify(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart
|
||||
) {
|
||||
message.warning(
|
||||
'商品或采集信息在 AI 处理期间已变化,结果未自动回填,请重新点击 AI 填报'
|
||||
@@ -1127,7 +1123,10 @@ const ProductEditPage = () => {
|
||||
setSkuCopyResult(null);
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
const formAtStart = JSON.stringify(form.getFieldsValue(true));
|
||||
// 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。
|
||||
const formAtStart = JSON.stringify(
|
||||
normalizedValues(form.getFieldsValue(true) as EditorFormValues)
|
||||
);
|
||||
const task = await productEditorApi.aiSkuCopy(
|
||||
snapshot.profile.id,
|
||||
skuCopyVariantId,
|
||||
@@ -1140,7 +1139,7 @@ const ProductEditPage = () => {
|
||||
if (
|
||||
latest.product.revision !== completed.input_revision.product ||
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
JSON.stringify(form.getFieldsValue(true)) !== formAtStart
|
||||
JSON.stringify(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart
|
||||
)
|
||||
throw new Error('商品已被其他操作更新,请重新生成');
|
||||
skuCopyTaskId.current = completed.id;
|
||||
@@ -1181,12 +1180,6 @@ const ProductEditPage = () => {
|
||||
message.success('已应用到当前 SKU,保存草稿后生效');
|
||||
};
|
||||
const skuCopyTaskId = useRef<string | null>(null);
|
||||
const showFutureFeature = (name: string) =>
|
||||
modal.info({
|
||||
title: `${name}将在下一阶段接入`,
|
||||
content: '单张 AI 编辑已可在 SKU 图片选择中使用。AI 套图生成仍在开发。',
|
||||
okText: '知道了',
|
||||
});
|
||||
const validSupplyUrl =
|
||||
typeof supplyUrl === 'string' && /^https?:\/\//i.test(supplyUrl) ? supplyUrl : null;
|
||||
const canOpenSupplyUrl = !!validSupplyUrl;
|
||||
@@ -1584,6 +1577,7 @@ const ProductEditPage = () => {
|
||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||
onAttributeChanged={() => setDirty(true)}
|
||||
onImagesChanged={() => setDirty(true)}
|
||||
suiteImagesByVariant={suiteImages}
|
||||
/>
|
||||
|
||||
<ProductOtherSection
|
||||
@@ -1617,7 +1611,7 @@ const ProductEditPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => showFutureFeature('AI 套图生成')}
|
||||
onClick={() => setSuiteOpen(true)}
|
||||
>
|
||||
AI 套图生成
|
||||
</Button>
|
||||
@@ -1650,6 +1644,18 @@ const ProductEditPage = () => {
|
||||
/>
|
||||
</Space>
|
||||
</footer>
|
||||
<ProductImageSuiteDrawer open={suiteOpen} onClose={() => setSuiteOpen(false)} snapshot={snapshot} form={form}
|
||||
source={sourceDraft ?? snapshot.source_material} collection={collectionRecord}
|
||||
onApply={(variantId, images) => {
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
const index = variants.findIndex((variant) => variant.id === variantId);
|
||||
if (index < 0) throw new Error('目标 SKU 已被删除,请重新选择');
|
||||
const ids = [...new Set([...(variants[index].media_asset_ids ?? []), ...images.flatMap((image) => image.assetId ? [image.assetId] : [])])];
|
||||
if (ids.length > 30) throw new Error('当前 SKU 最多 30 张图片,请先移除部分图片');
|
||||
form.setFieldValue(['variants', index, 'media_asset_ids'], ids);
|
||||
setSuiteImages((current) => ({ ...current, [variantId]: [...(current[variantId] ?? []), ...images] }));
|
||||
setDirty(true);
|
||||
}} />
|
||||
<ProductCopyComparisonModal
|
||||
open={!!comparison}
|
||||
scopeLabel={
|
||||
|
||||
@@ -436,7 +436,9 @@ export function DynamicAttributeFields({
|
||||
colorNameDefinition,
|
||||
onAttributeChanged,
|
||||
}: Props) {
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
// 属性条目可在 schema 已缓存时被重新补齐,监听表单以及时恢复对应控件。
|
||||
const attributes = (Form.useWatch('attributes', { form, preserve: true }) ??
|
||||
[]) as AttributeDraft[];
|
||||
const fields = definitions.flatMap((definition) => {
|
||||
const index = attributeIndex(attributes, definition, scope, variantId);
|
||||
return index < 0 ? [] : [{ definition, index }];
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, App, Button, Empty, Image, Modal, Spin } from 'antd';
|
||||
import type { CollectionItemInspection } from '@atlas/contracts';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { mediaApi, uploadProductImage } from '@/services/media';
|
||||
import { SkuImageSourceTabs } from './SkuImageSourceTabs';
|
||||
import { useSkuImageLibrary } from './useSkuImageLibrary';
|
||||
import { SkuImageCard, type SkuEditorImage } from './SkuImageCard';
|
||||
import './SkuImageEditorModal.css';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
productId: string;
|
||||
variantId: string;
|
||||
selected: SkuEditorImage[];
|
||||
candidates: CollectionItemInspection['asset_transfers'];
|
||||
onCancel: () => void;
|
||||
onConfirm: (images: SkuEditorImage[]) => void;
|
||||
};
|
||||
|
||||
export function ImageSuiteReferencePicker({
|
||||
open,
|
||||
productId,
|
||||
variantId,
|
||||
selected,
|
||||
candidates,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [draft, setDraft] = useState<SkuEditorImage[]>([]);
|
||||
const [tab, setTab] = useState('collection');
|
||||
const [ai, setAi] = useState<SkuEditorImage[]>([]);
|
||||
const [processed, setProcessed] = useState<SkuEditorImage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reload, setReload] = useState(0);
|
||||
const dragIndex = useRef<number | null>(null);
|
||||
const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>(
|
||||
null
|
||||
);
|
||||
const upload = async (file: File): Promise<SkuEditorImage> => {
|
||||
const asset = await uploadProductImage(file),
|
||||
access = await mediaApi.createPreviewUrl(asset.id);
|
||||
return {
|
||||
id: `asset:${asset.id}`,
|
||||
assetId: asset.id,
|
||||
url: access.url,
|
||||
label: file.name,
|
||||
origin: 'upload',
|
||||
};
|
||||
};
|
||||
const library = useSkuImageLibrary(open, variantId, draft, upload);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(selected);
|
||||
setTab('collection');
|
||||
setPreview(null);
|
||||
dragIndex.current = null;
|
||||
}
|
||||
}, [open, variantId]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
void Promise.all([
|
||||
mediaApi.listAiEdits(productId),
|
||||
mediaApi.listProductImageDerivatives(productId),
|
||||
])
|
||||
.then(([edits, derivatives]) => {
|
||||
if (!active) return;
|
||||
setAi(
|
||||
edits.flatMap((edit) =>
|
||||
edit.output_asset
|
||||
? [
|
||||
{
|
||||
id: `asset:${edit.output_asset.id}`,
|
||||
assetId: edit.output_asset.id,
|
||||
url: edit.output_asset.preview_url,
|
||||
label: 'AI 生成图',
|
||||
origin: 'derivative' as const,
|
||||
},
|
||||
]
|
||||
: []
|
||||
)
|
||||
);
|
||||
setProcessed(
|
||||
derivatives.map((record) => ({
|
||||
id: `asset:${record.output_asset.id}`,
|
||||
assetId: record.output_asset.id,
|
||||
url: record.output_asset.preview_url,
|
||||
label: '图片处理结果',
|
||||
origin: 'derivative' as const,
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (active) setError(apiErrorMessage(cause));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, productId, reload]);
|
||||
const same = (a: SkuEditorImage, b: SkuEditorImage) =>
|
||||
a.id === b.id || (!!a.assetId && a.assetId === b.assetId);
|
||||
const toggle = (image: SkuEditorImage) => {
|
||||
if (draft.some((item) => same(item, image)))
|
||||
setDraft((items) => items.filter((item) => !same(item, image)));
|
||||
else if (draft.length < 8) setDraft((items) => [...items, image]);
|
||||
else message.warning('最多选择 8 张底图,请先移除一张');
|
||||
};
|
||||
const gallery = (images: SkuEditorImage[]) =>
|
||||
images.length ? (
|
||||
<div className="ai-suite-picker-grid">
|
||||
{images.map((image, index) => (
|
||||
<SkuImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
mode="source"
|
||||
selected={draft.some((item) => same(item, image))}
|
||||
onToggle={() => toggle(image)}
|
||||
onPreview={() => setPreview({ images, index })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
|
||||
);
|
||||
const collected = (role: string): SkuEditorImage[] =>
|
||||
candidates
|
||||
.filter((item) => item.role === role)
|
||||
.map((item) => ({
|
||||
id: `collection:${item.id}`,
|
||||
transferId: item.id,
|
||||
assetId: item.asset_id ?? undefined,
|
||||
url: item.source_url,
|
||||
label:
|
||||
role === 'source_sku'
|
||||
? 'SKU 图'
|
||||
: role === 'source_detail'
|
||||
? '详情图'
|
||||
: '采集主图',
|
||||
origin: 'collection',
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="选择套图参考底图"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={() => onConfirm(draft)}
|
||||
okText="确认选择"
|
||||
width="min(1320px, 96vw)"
|
||||
zIndex={1200}
|
||||
className="sku-image-editor-modal ai-suite-picker"
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="ai-suite-picker-summary">
|
||||
<span className="ai-suite-picker-count">已选 {draft.length} / 8 张</span>
|
||||
<span className="ai-suite-picker-hint">
|
||||
最多 8 张 · 建议 3–6 张不同角度与细节图,第一张用于确定商品主体。
|
||||
</span>
|
||||
</div>
|
||||
<div className="ai-suite-picker-columns">
|
||||
<section className="ai-suite-picker-selected" aria-label="已选套图底图">
|
||||
<div className="ai-suite-picker-section-title">
|
||||
<strong>已选底图</strong>
|
||||
<span>拖动调整顺序 · 第一张为主体图</span>
|
||||
</div>
|
||||
<div className="ai-suite-picker-selected-scroll">
|
||||
{draft.length ? (
|
||||
<div className="ai-suite-picker-selected-grid">
|
||||
{draft.map((image, index) => (
|
||||
<div key={image.id}>
|
||||
<SkuImageCard
|
||||
image={{
|
||||
...image,
|
||||
label:
|
||||
index === 0
|
||||
? '主体图'
|
||||
: `参考图 ${index + 1}`,
|
||||
}}
|
||||
mode="selected"
|
||||
selected={false}
|
||||
cover={index === 0}
|
||||
coverLabel="主体图"
|
||||
onToggle={() =>
|
||||
setPreview({ images: draft, index })
|
||||
}
|
||||
onPreview={() =>
|
||||
setPreview({ images: draft, index })
|
||||
}
|
||||
onRemove={() => toggle(image)}
|
||||
onDragStart={() => {
|
||||
dragIndex.current = index;
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const from = dragIndex.current;
|
||||
dragIndex.current = null;
|
||||
if (from === null || from === index) return;
|
||||
setDraft((items) => {
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved) next.splice(index, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="ai-suite-picker-selected-caption">
|
||||
<strong>
|
||||
{index === 0
|
||||
? '01 · 主体图'
|
||||
: `${String(index + 1).padStart(2, '0')} · 参考图`}
|
||||
</strong>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setDraft([
|
||||
image,
|
||||
...draft.filter(
|
||||
(item) => !same(item, image)
|
||||
),
|
||||
])
|
||||
}
|
||||
>
|
||||
设为主体
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="从右侧选择商品图片"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="sku-image-editor-right ai-suite-picker-sources"
|
||||
aria-label="套图底图来源"
|
||||
>
|
||||
{error && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={error}
|
||||
action={
|
||||
<Button onClick={() => setReload((value) => value + 1)}>
|
||||
重新加载
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Spin spinning={loading}>
|
||||
<SkuImageSourceTabs
|
||||
activeTab={tab}
|
||||
onTabChange={setTab}
|
||||
sourceImages={collected}
|
||||
renderImages={gallery}
|
||||
aiImages={ai}
|
||||
renderProcessedImages={() => gallery(processed)}
|
||||
networkImages={library.networkImages}
|
||||
networkUrls={library.networkUrls}
|
||||
onNetworkUrlsChange={library.setNetworkUrls}
|
||||
networkAdding={library.networkAdding}
|
||||
onAddNetworkImages={() => void library.addNetworkImages()}
|
||||
uploadedImages={library.uploadedImages}
|
||||
uploading={library.uploading}
|
||||
onUploadFile={library.uploadToRight}
|
||||
tabOrder={['collection', 'ai', 'processed', 'upload', 'network']}
|
||||
/>
|
||||
</Spin>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
<Image.PreviewGroup
|
||||
items={preview?.images.map((image) => image.url) ?? []}
|
||||
preview={{
|
||||
open: !!preview,
|
||||
zIndex: 1300,
|
||||
current: preview?.index ?? 0,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) setPreview(null);
|
||||
},
|
||||
onChange: (index) =>
|
||||
setPreview((current) => (current ? { ...current, index } : null)),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { ozonHashtagProblem } from '@atlas/contracts';
|
||||
import { EditOutlined, PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
@@ -75,6 +76,8 @@ export function ProductAttributeSection({
|
||||
}: Props) {
|
||||
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||
const [richContent, setRichContent] = useState('');
|
||||
const tags = Form.useWatch(['profile', 'tags'], { form, preserve: true }) ?? [];
|
||||
const tagsProblem = ozonHashtagProblem(tags);
|
||||
const commonDefinitions = useMemo(
|
||||
() =>
|
||||
activeAttributeSchema.filter(
|
||||
@@ -97,14 +100,19 @@ export function ProductAttributeSection({
|
||||
title="产品属性"
|
||||
description="用于当前平台发布与 AI 文案生成。"
|
||||
/>
|
||||
<Form.Item label="主题标签">
|
||||
<Form.Item
|
||||
label="主题标签"
|
||||
validateStatus={tagsProblem ? 'warning' : undefined}
|
||||
help={tagsProblem ?? undefined}
|
||||
extra="以 # 开头,只用字母、数字和下划线;每个最多 30 字符,最多 30 个。"
|
||||
>
|
||||
<div className="profile-override-control">
|
||||
<Form.Item name={['profile', 'tags']} noStyle>
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="#спортивный_стиль,空格分隔不同标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
{!usesVariantTags && (
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button, Popconfirm, Progress } from 'antd';
|
||||
import { LoadingOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import type { ImageSuite, ImageSuiteInput } from '@atlas/contracts';
|
||||
|
||||
type Props = {
|
||||
input: ImageSuiteInput;
|
||||
imageCount: number;
|
||||
task: ImageSuite | null;
|
||||
planTask: ImageSuite | null;
|
||||
submitting: boolean;
|
||||
cancelling: boolean;
|
||||
disabled: boolean;
|
||||
retryDisabled: boolean;
|
||||
onGenerate: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: (task: ImageSuite) => Promise<void>;
|
||||
};
|
||||
|
||||
const isActive = (task: ImageSuite | null) => !!task && ['queued', 'running'].includes(task.status);
|
||||
const languageLabels = { ru: '俄语', zh: '中文', en: '英文', ar: '阿拉伯语' };
|
||||
|
||||
export function ProductImageSuiteActions({
|
||||
input,
|
||||
imageCount,
|
||||
task,
|
||||
planTask,
|
||||
submitting,
|
||||
cancelling,
|
||||
disabled,
|
||||
retryDisabled,
|
||||
onGenerate,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const pendingTask = isActive(planTask) ? planTask : isActive(task) ? task : null;
|
||||
const planning = pendingTask?.purpose === 'plan';
|
||||
const completed = task?.items.filter((item) => !!item.output_asset).length ?? 0;
|
||||
const retryCount =
|
||||
task && ['partial', 'failed', 'cancelled'].includes(task.status)
|
||||
? task.items.filter((item) => ['failed', 'cancelled'].includes(item.status)).length
|
||||
: 0;
|
||||
// 执行中和重试时展示任务快照,避免用户调整下一轮方案后看到错误的任务配置。
|
||||
const displayedTask = pendingTask ?? (retryCount ? task : null);
|
||||
const displayedInput = displayedTask?.input ?? input;
|
||||
const displayedCount = displayedTask
|
||||
? displayedTask.purpose === 'plan' && 'image_count' in displayedTask.input
|
||||
? displayedTask.input.image_count
|
||||
: displayedTask.shots.length
|
||||
: imageCount;
|
||||
const progress = Math.round((completed / Math.max(displayedCount, 1)) * 100);
|
||||
|
||||
return (
|
||||
<footer className="ai-suite-submit" aria-label="本次生成操作区" aria-live="polite">
|
||||
<div className="ai-suite-heading">
|
||||
<span>{planning ? '本次规划' : '本次生成'}</span>
|
||||
<strong>
|
||||
{displayedCount} 张 · {displayedInput.ratio} ·{' '}
|
||||
{languageLabels[displayedInput.language]}
|
||||
</strong>
|
||||
</div>
|
||||
{pendingTask ? (
|
||||
<>
|
||||
<div className="ai-suite-submit-actions">
|
||||
<Popconfirm
|
||||
key={pendingTask.id}
|
||||
placement="topLeft"
|
||||
title={planning ? '取消本次智能规划?' : '取消本次生成?'}
|
||||
description={
|
||||
<div className="ai-suite-cancel-description">
|
||||
{planning
|
||||
? '取消后保留当前出图方案,规划结果不会覆盖当前方案。'
|
||||
: `取消未完成图片,已完成的 ${completed} 张会保留。`}
|
||||
</div>
|
||||
}
|
||||
okText="确认取消"
|
||||
cancelText={planning ? '继续规划' : '继续生成'}
|
||||
okButtonProps={{ danger: true, loading: cancelling }}
|
||||
cancelButtonProps={{ disabled: cancelling }}
|
||||
disabled={cancelling}
|
||||
zIndex={1200}
|
||||
onConfirm={() => onCancel(pendingTask)}
|
||||
>
|
||||
<Button className="ai-suite-cancel" loading={cancelling}>
|
||||
{planning ? '取消规划' : '取消任务'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<div className="ai-suite-submit-status" role="status">
|
||||
<LoadingOutlined />
|
||||
<span>
|
||||
{cancelling
|
||||
? '正在取消…'
|
||||
: planning
|
||||
? pendingTask.status === 'queued'
|
||||
? '规划排队中'
|
||||
: '智能规划中'
|
||||
: `${pendingTask.status === 'queued' ? '排队中' : '生成中'} · ${completed} / ${displayedCount}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!planning && (
|
||||
<Progress
|
||||
className="ai-suite-submit-progress"
|
||||
percent={progress}
|
||||
showInfo={false}
|
||||
status="active"
|
||||
size="small"
|
||||
aria-label={`已完成 ${completed} / ${displayedCount} 张`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : retryCount ? (
|
||||
<>
|
||||
<p className="ai-suite-submit-feedback">
|
||||
{task?.status === 'cancelled' ? '任务已取消' : '部分图片未完成'} · 已完成{' '}
|
||||
{completed} 张
|
||||
</p>
|
||||
<Button
|
||||
className="ai-suite-generate"
|
||||
type="primary"
|
||||
block
|
||||
icon={<ReloadOutlined />}
|
||||
loading={submitting}
|
||||
disabled={retryDisabled}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试未完成的 {retryCount} 张
|
||||
</Button>
|
||||
<Button
|
||||
className="ai-suite-new-generation"
|
||||
type="link"
|
||||
disabled={disabled}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
按当前方案重新生成
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="ai-suite-generate"
|
||||
type="primary"
|
||||
block
|
||||
icon={<RobotOutlined />}
|
||||
loading={submitting}
|
||||
disabled={disabled}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
开始生成
|
||||
</Button>
|
||||
)}
|
||||
<p className="ai-suite-help">
|
||||
{pendingTask
|
||||
? planning
|
||||
? '可关闭抽屉,规划会继续;完成后请核对方案。'
|
||||
: '可关闭抽屉继续编辑,任务会在后台继续生成。'
|
||||
: retryCount
|
||||
? '成功图片已保留,重试只生成未完成图片。'
|
||||
: '可关闭抽屉继续编辑,生成图保存在 AI 生图记录。'}
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
.product-ai-suite-drawer .ant-drawer-body {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
container-type: size;
|
||||
}
|
||||
.ai-suite-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(273px, 0.9975fr) minmax(330.6px, 1.14fr) minmax(296.4px, 1.1125fr);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ai-suite-column {
|
||||
padding: 24px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.ai-suite-column.ai-suite-strategy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
.ai-suite-strategy-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.ai-suite-column + .ai-suite-column {
|
||||
border-left: 1px solid var(--border-color, #e7e8ee);
|
||||
}
|
||||
.ai-suite-column h3 {
|
||||
margin: 0 0 22px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 17px;
|
||||
}
|
||||
.ai-suite-column h3 > span:first-child {
|
||||
color: #9293a4;
|
||||
font-size: 12px;
|
||||
background: #f3f3f7;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.ai-suite-column h3 .ant-tag,
|
||||
.ai-suite-column h3 .ant-typography {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin: 14px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ai-suite-field .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
.ai-suite-dimensions,
|
||||
.ai-suite-weight {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) 66px;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-suite-weight {
|
||||
grid-template-columns: minmax(0, 1fr) 66px;
|
||||
}
|
||||
.ai-suite-dimensions .ant-input-number,
|
||||
.ai-suite-weight .ant-input-number {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-suite-block {
|
||||
margin: 22px 0;
|
||||
}
|
||||
.ai-suite-help {
|
||||
color: #818293;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ai-suite-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-references {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.ai-suite-references > div {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #818293;
|
||||
}
|
||||
.ai-suite-references .ant-image {
|
||||
width: 100%;
|
||||
}
|
||||
.ai-suite-references img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
border: 1px solid #ececf2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ai-suite-references span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ai-suite-plans {
|
||||
background: transparent;
|
||||
}
|
||||
.ai-suite-plan-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ai-suite-plans .ant-collapse-header {
|
||||
align-items: center !important;
|
||||
padding: 12px 10px !important;
|
||||
}
|
||||
.ai-suite-plans .ant-collapse-header-text {
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-suite-count {
|
||||
--ai-suite-count-height: 28.8px;
|
||||
}
|
||||
.ai-suite-count .ant-btn {
|
||||
width: 28px;
|
||||
height: var(--ai-suite-count-height);
|
||||
padding-inline: 0;
|
||||
}
|
||||
.ai-suite-plans .ai-suite-count .ant-input-number {
|
||||
width: 40px;
|
||||
height: var(--ai-suite-count-height);
|
||||
}
|
||||
.ai-suite-count .ant-input-number-input {
|
||||
height: calc(var(--ai-suite-count-height) - 2px);
|
||||
text-align: center;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
.ai-suite-group-summary {
|
||||
color: #858696;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-suite-plans .ant-collapse-content-box {
|
||||
padding: 8px 10px !important;
|
||||
}
|
||||
.ai-suite-shot-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.ai-suite-shot {
|
||||
border: 1px solid #e8e8f1;
|
||||
border-radius: 8px;
|
||||
background: #fafaff;
|
||||
padding: 10px;
|
||||
}
|
||||
.ai-suite-shot > strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.ai-suite-shot p {
|
||||
color: #686979;
|
||||
font-size: 12px;
|
||||
margin: 0 0 7px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-suite-shot small {
|
||||
color: #999aaa;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-suite-style-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.ai-suite-style-options .ant-btn {
|
||||
font-size: 12px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
.ai-suite-style-description .ant-collapse-header {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
.ai-suite-style-description .ant-collapse-body,
|
||||
.ai-suite-style-description .ant-collapse-content-box {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
.ai-suite-style-reset.ant-btn {
|
||||
padding-inline: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ai-suite-config {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.ai-suite-submit {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e8f1;
|
||||
padding: 16px 24px;
|
||||
box-shadow: 0 -4px 16px #33224d06;
|
||||
}
|
||||
.ai-suite-submit > .ant-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ai-suite-submit > .ai-suite-generate {
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ai-suite-submit-actions {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ai-suite-cancel.ant-btn,
|
||||
.ai-suite-submit-status {
|
||||
min-height: 48px;
|
||||
}
|
||||
.ai-suite-submit-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 7px;
|
||||
background: #f5f1ff;
|
||||
color: var(--primary-color, #8457f5);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-suite-submit-progress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ai-suite-submit-feedback {
|
||||
color: #818293;
|
||||
font-size: 12px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.ai-suite-submit > .ai-suite-new-generation {
|
||||
display: block;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-submit .ai-suite-help:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.ai-suite-cancel-description {
|
||||
max-width: 250px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.ai-suite-results {
|
||||
background: #fbfbfe;
|
||||
}
|
||||
.ai-suite-history {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ai-suite-history .ant-select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ai-suite-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.ai-suite-result {
|
||||
border: 1px solid #e8e8f1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-suite-result-image {
|
||||
background: #f4f4f8;
|
||||
}
|
||||
.ai-suite-result-image .ant-image,
|
||||
.ai-suite-result-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ai-suite-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a0a1b2;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-placeholder > .anticon {
|
||||
font-size: 27px;
|
||||
}
|
||||
.ai-suite-result > .ai-suite-heading {
|
||||
padding: 10px;
|
||||
}
|
||||
.ai-suite-result > .ai-suite-heading span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-suite-failure {
|
||||
padding: 0 10px;
|
||||
color: #c13b3b;
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-suite-apply {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.ai-suite-apply .ai-suite-help {
|
||||
width: 100%;
|
||||
}
|
||||
.ai-suite-alert {
|
||||
margin: 12px;
|
||||
}
|
||||
.ai-suite-picker-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-suite-picker-count {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-suite-picker-hint {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
.ai-suite-picker-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr);
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ai-suite-picker-selected,
|
||||
.ai-suite-picker-sources {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ai-suite-picker-selected {
|
||||
padding-right: 24px;
|
||||
border-right: 1px solid #ece9f1;
|
||||
}
|
||||
.ai-suite-picker-section-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ai-suite-picker-section-title span {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-picker-selected-scroll {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-suite-picker-selected-grid,
|
||||
.ai-suite-picker-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 3px;
|
||||
}
|
||||
.ai-suite-picker-selected-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.ai-suite-picker-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.ai-suite-picker .sku-image-selected-card.is-cover {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
.ai-suite-picker .sku-image-selected-card:focus-visible,
|
||||
.ai-suite-picker .sku-image-source-card:focus-visible {
|
||||
outline: 2px solid #7062e9;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ai-suite-picker-selected-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
min-height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-picker-selected-caption .ant-btn {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-suite-picker-sources > .ant-spin,
|
||||
.ai-suite-picker-sources > .ant-spin > .ant-spin-container {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.ai-suite-picker-columns {
|
||||
gap: 16px;
|
||||
}
|
||||
.ai-suite-picker-selected {
|
||||
padding-right: 16px;
|
||||
}
|
||||
.ai-suite-picker-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.ai-suite-column {
|
||||
padding: 16px;
|
||||
}
|
||||
.ai-suite-strategy-scroll,
|
||||
.ai-suite-submit {
|
||||
padding: 16px;
|
||||
}
|
||||
.ai-suite-columns {
|
||||
grid-template-columns: minmax(246.75px, 0.9975fr) minmax(296.4px, 1.14fr) minmax(
|
||||
241.85px,
|
||||
0.9125fr
|
||||
);
|
||||
}
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.product-ai-suite-drawer .ant-drawer-body {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-suite-columns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
.ai-suite-column {
|
||||
overflow-y: visible;
|
||||
}
|
||||
.ai-suite-column.ai-suite-strategy {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100cqh;
|
||||
}
|
||||
.ai-suite-results {
|
||||
grid-column: 1 / -1;
|
||||
border-left: none !important;
|
||||
border-top: 1px solid #e7e8ee;
|
||||
}
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
.ai-suite-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ai-suite-column.ai-suite-strategy {
|
||||
position: static;
|
||||
}
|
||||
.ai-suite-column + .ai-suite-column {
|
||||
border-left: none;
|
||||
border-top: 1px solid #e7e8ee;
|
||||
}
|
||||
.ai-suite-picker-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 580px) {
|
||||
.ai-suite-picker-columns {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 0.7fr) minmax(0, 1.3fr);
|
||||
}
|
||||
.ai-suite-picker-selected {
|
||||
padding-right: 0;
|
||||
padding-bottom: 12px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #ece9f1;
|
||||
}
|
||||
.ai-suite-picker-selected-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { ClockCircleOutlined, LoadingOutlined, ReloadOutlined } from '@ant-desig
|
||||
import type { ProductImageTask } from '@atlas/contracts';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi } from '@/services/media';
|
||||
import { imageSuitesApi } from '@/services/imageSuites';
|
||||
import './ProductImageTaskCenter.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -82,7 +83,8 @@ export function ProductImageTaskCenter({ productId }: { productId: string }) {
|
||||
const cancel = async (task: ProductImageTask) => {
|
||||
setCancellingId(task.id);
|
||||
try {
|
||||
await mediaApi.cancelAiEdit(task.id);
|
||||
if (task.kind === 'ai_suite') await imageSuitesApi.cancel(productId, task.id);
|
||||
else await mediaApi.cancelAiEdit(task.id);
|
||||
await refresh();
|
||||
message.success('任务已取消');
|
||||
} catch (cause) {
|
||||
@@ -121,18 +123,18 @@ export function ProductImageTaskCenter({ productId }: { productId: string }) {
|
||||
/>}
|
||||
{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">
|
||||
{(task.status === 'failed' || task.status === 'partial') && task.error_code && <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">
|
||||
{(task.status === 'failed' || task.status === 'partial') && 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="返回"
|
||||
{activeStatuses.has(task.status) && <Popconfirm
|
||||
title="取消这个图片任务?" okText="取消任务" cancelText="返回"
|
||||
onConfirm={() => void cancel(task)}>
|
||||
<Button type="link" danger htmlType="button" loading={cancellingId === task.id}>取消任务</Button>
|
||||
</Popconfirm>}
|
||||
|
||||
@@ -101,8 +101,9 @@ export function ProductPublishActions({
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [job?.id, job?.status, platformId]);
|
||||
const eligible = (data: ProductEditorSnapshot) =>
|
||||
data.variants.filter((v) => ['not_listed', 'failed'].includes(v.listing.status));
|
||||
const canSelectSku = (variant: ProductEditorSnapshot['variants'][number]) =>
|
||||
['not_listed', 'failed'].includes(variant.listing.status);
|
||||
const eligible = (data: ProductEditorSnapshot) => data.variants.filter(canSelectSku);
|
||||
const skipReason = (status: string) =>
|
||||
status === 'imported'
|
||||
? '已有成功导入记录,不能重复提交'
|
||||
@@ -400,6 +401,28 @@ export function ProductPublishActions({
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
dataSource={saved.variants}
|
||||
onRow={(variant) => ({
|
||||
style: {
|
||||
cursor: canSelectSku(variant) ? 'pointer' : 'not-allowed',
|
||||
},
|
||||
onClick: (event) => {
|
||||
if (!canSelectSku(variant)) return;
|
||||
// 复选框保留自身选择事件,避免冒泡到行时切换两次。
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest(
|
||||
'input, button, a, label, .ant-checkbox-wrapper'
|
||||
)
|
||||
)
|
||||
return;
|
||||
setIds((selected) =>
|
||||
selected.includes(variant.id)
|
||||
? selected.filter((id) => id !== variant.id)
|
||||
: [...selected, variant.id]
|
||||
);
|
||||
setPreflight(null);
|
||||
},
|
||||
})}
|
||||
rowSelection={{
|
||||
selectedRowKeys: ids,
|
||||
onChange: (keys) => {
|
||||
@@ -407,9 +430,7 @@ export function ProductPublishActions({
|
||||
setPreflight(null);
|
||||
},
|
||||
getCheckboxProps: (v) => ({
|
||||
disabled: !['not_listed', 'failed'].includes(
|
||||
v.listing.status
|
||||
),
|
||||
disabled: !canSelectSku(v),
|
||||
}),
|
||||
}}
|
||||
columns={[
|
||||
@@ -441,10 +462,15 @@ export function ProductPublishActions({
|
||||
{ title: 'SKU', dataIndex: 'display_name' },
|
||||
{ title: '货号', render: (_, v) => v.listing.offer_id },
|
||||
{
|
||||
title: '售价',
|
||||
title: '实际售价',
|
||||
render: (_, v) =>
|
||||
`${v.listing.selling_price} ${saved.profile.target_store.currency_code}`,
|
||||
},
|
||||
{
|
||||
title: '划线价(本次上报价)',
|
||||
render: (_, v) =>
|
||||
`${v.listing.list_price ?? '未确认'} ${saved.profile.target_store.currency_code}`,
|
||||
},
|
||||
{ title: '图片', render: (_, v) => `${v.media.length} 张` },
|
||||
]}
|
||||
/>
|
||||
@@ -474,6 +500,7 @@ export function ProductPublishActions({
|
||||
type="success"
|
||||
showIcon
|
||||
title={`预检通过,将提交 ${ids.length} 个 SKU`}
|
||||
description="首次上架按划线价发布,上线后可在 Ozon 调整实际售价。"
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
.sku-main-count {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 8px;
|
||||
left: 4px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
background: rgb(41 36 51 / 82%);
|
||||
@@ -92,10 +92,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-main-badge {
|
||||
top: 8px;
|
||||
top: 4px;
|
||||
}
|
||||
.sku-main-count {
|
||||
bottom: 8px;
|
||||
bottom: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -185,7 +185,7 @@
|
||||
}
|
||||
.sku-pricing-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -272,7 +272,7 @@
|
||||
}
|
||||
.sku-pricing-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 11px;
|
||||
}
|
||||
.sku-pricing-fields > label,
|
||||
@@ -619,11 +619,15 @@
|
||||
.sku-pricing-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sku-pricing-fields > div:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@container (max-width: 430px) {
|
||||
.sku-pricing-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sku-pricing-summary > div:first-child,
|
||||
.sku-pricing-summary > div:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.sku-pricing-summary strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ type Props = {
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onAttributeChanged: () => void;
|
||||
onImagesChanged: () => void;
|
||||
suiteImagesByVariant?: Record<string, SkuEditorImage[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,6 +138,7 @@ export function ProductVariantSection({
|
||||
onCompareCopy,
|
||||
onAttributeChanged,
|
||||
onImagesChanged,
|
||||
suiteImagesByVariant = {},
|
||||
}: Props) {
|
||||
const [pricingAuto, setPricingAuto] = useState(
|
||||
() => localStorage.getItem('atlas.pricing.auto') !== 'false'
|
||||
@@ -166,11 +168,14 @@ export function ProductVariantSection({
|
||||
const refresh = async () => {
|
||||
const ids = [
|
||||
...new Set(
|
||||
snapshot.variants.flatMap((variant) =>
|
||||
variant.media
|
||||
.filter((media) => !media.source_url)
|
||||
.map((media) => media.asset_id)
|
||||
)
|
||||
[
|
||||
...snapshot.variants.flatMap((variant) =>
|
||||
variant.media.map((media) => media.asset_id)
|
||||
),
|
||||
...(collectionRecord?.asset_transfers ?? []).flatMap((transfer) =>
|
||||
transfer.asset_id ? [transfer.asset_id] : []
|
||||
),
|
||||
]
|
||||
),
|
||||
];
|
||||
const previews = await Promise.allSettled(
|
||||
@@ -191,7 +196,7 @@ export function ProductVariantSection({
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [snapshot]);
|
||||
}, [snapshot, collectionRecord]);
|
||||
// SKU 类目属性放入展开后的“规格与发布信息”,必填状态仍由 schema 决定。
|
||||
const skuInfoAspectDefinitions = activeAttributeSchema
|
||||
.filter((definition) => definition.is_aspect && !definition.editor_field)
|
||||
@@ -298,12 +303,13 @@ export function ProductVariantSection({
|
||||
const candidate = imageCandidates.find(
|
||||
(item) => item.asset_id === assetId
|
||||
);
|
||||
// 来源地址可能过期,已转存素材始终优先使用新签发的预览链接。
|
||||
const url =
|
||||
assetPreviewUrls[assetId] ??
|
||||
candidate?.source_url ??
|
||||
original?.media.find(
|
||||
(media) => media.asset_id === assetId
|
||||
)?.source_url ??
|
||||
assetPreviewUrls[assetId];
|
||||
)?.source_url;
|
||||
return url
|
||||
? {
|
||||
id: candidate
|
||||
@@ -317,8 +323,15 @@ export function ProductVariantSection({
|
||||
: null;
|
||||
})
|
||||
.filter((image): image is SkuEditorImage => image !== null);
|
||||
const selectedImages =
|
||||
localImagesByVariant[variantKey] ?? savedImages;
|
||||
const previewByAsset = new Map([
|
||||
...savedImages,
|
||||
...(localImagesByVariant[variantKey] ?? []),
|
||||
...(suiteImagesByVariant[variantKey] ?? []),
|
||||
].filter((image) => image.assetId).map((image) => [image.assetId!, image]));
|
||||
const selectedImages = selectedAssetIds.flatMap((id) => {
|
||||
const image = previewByAsset.get(id);
|
||||
return image ? [{ ...image, url: assetPreviewUrls[id] ?? image.url }] : [];
|
||||
});
|
||||
const sourceMainCandidate =
|
||||
!selectedAssetIds.length &&
|
||||
!selectedImages.length &&
|
||||
@@ -333,7 +346,7 @@ export function ProductVariantSection({
|
||||
? [
|
||||
{
|
||||
id: `collection:${sourceMainCandidate.id}`,
|
||||
url: sourceMainCandidate.source_url,
|
||||
url: (sourceMainCandidate.asset_id && assetPreviewUrls[sourceMainCandidate.asset_id]) ?? sourceMainCandidate.source_url,
|
||||
label: '来源 SKU 图片',
|
||||
origin: 'collection',
|
||||
transferId: sourceMainCandidate.id,
|
||||
@@ -612,6 +625,7 @@ export function ProductVariantSection({
|
||||
skuKey={imageEditorTarget?.key ?? ''}
|
||||
skuName={imageEditorTarget?.name ?? ''}
|
||||
initialImages={imageEditorTarget?.images ?? []}
|
||||
previewUrls={assetPreviewUrls}
|
||||
collectionImages={imageEditorTarget?.candidates ?? []}
|
||||
onCancel={() => setImageEditorTarget(null)}
|
||||
onConfirm={async (images) => {
|
||||
|
||||
@@ -23,7 +23,8 @@ type Props = {
|
||||
mode: 'selected' | 'source';
|
||||
selected: boolean;
|
||||
cover?: boolean;
|
||||
menu: MenuProps;
|
||||
coverLabel?: string;
|
||||
menu?: MenuProps;
|
||||
onToggle: () => void;
|
||||
onPreview: () => void;
|
||||
onRemove?: () => void;
|
||||
@@ -38,6 +39,7 @@ export function SkuImageCard({
|
||||
mode,
|
||||
selected,
|
||||
cover,
|
||||
coverLabel = '主图',
|
||||
menu,
|
||||
onToggle,
|
||||
onPreview,
|
||||
@@ -48,7 +50,7 @@ export function SkuImageCard({
|
||||
}: Props) {
|
||||
const isSelectedColumn = mode === 'selected';
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
@@ -69,7 +71,7 @@ export function SkuImageCard({
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<img src={image.url} alt={image.label} loading="lazy" draggable={false} />
|
||||
{cover && <span className="sku-image-cover-badge">主图</span>}
|
||||
{cover && <span className="sku-image-cover-badge">{coverLabel}</span>}
|
||||
{!isSelectedColumn && selected && (
|
||||
<Tooltip title="已加入">
|
||||
<CheckCircleOutlined className="sku-image-source-selected" />
|
||||
@@ -101,11 +103,13 @@ export function SkuImageCard({
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<Button type="text" onClick={stop}>
|
||||
编辑 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
{menu && (
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<Button type="text" onClick={stop}>
|
||||
编辑 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ type Props = {
|
||||
skuKey: string;
|
||||
skuName: string;
|
||||
initialImages: SkuEditorImage[];
|
||||
previewUrls: Record<string, string>;
|
||||
collectionImages: CollectionImage[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (images: SkuEditorImage[]) => Promise<void>;
|
||||
@@ -46,6 +47,7 @@ export function SkuImageEditorModal({
|
||||
skuKey,
|
||||
skuName,
|
||||
initialImages,
|
||||
previewUrls,
|
||||
collectionImages,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@@ -441,7 +443,7 @@ export function SkuImageEditorModal({
|
||||
.filter((image) => image.role === role)
|
||||
.map((image) => ({
|
||||
id: `collection:${image.id}`,
|
||||
url: image.source_url,
|
||||
url: (image.asset_id && previewUrls[image.asset_id]) ?? image.source_url,
|
||||
label:
|
||||
image.role === 'source_main'
|
||||
? '采集主图'
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
uploadedImages: SkuEditorImage[];
|
||||
uploading: boolean;
|
||||
onUploadFile: (file: File) => Promise<false | typeof Upload.LIST_IGNORE>;
|
||||
tabOrder?: string[];
|
||||
};
|
||||
|
||||
/** 图片来源分区只负责展示和输入,选中及素材状态由弹窗控制。 */
|
||||
@@ -44,6 +45,7 @@ export function SkuImageSourceTabs({
|
||||
uploadedImages,
|
||||
uploading,
|
||||
onUploadFile,
|
||||
tabOrder,
|
||||
}: Props) {
|
||||
return (
|
||||
<Tabs
|
||||
@@ -149,7 +151,7 @@ export function SkuImageSourceTabs({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
].sort((left, right) => tabOrder ? tabOrder.indexOf(left.key) - tabOrder.indexOf(right.key) : 0)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Form, Input, Select, Space, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { ozonHashtagProblem } from '@atlas/contracts';
|
||||
import { RobotOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
@@ -81,6 +82,13 @@ export function SkuInfoPanel({
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
// 通用模式也展示 SKU 实际使用的文案,预览不写入 SKU 覆盖字段。
|
||||
const profile = Form.useWatch('profile', { form, preserve: true }) as
|
||||
EditorFormValues['profile'] | undefined;
|
||||
const ownTags = Form.useWatch(['variants', fieldName, 'tags'], { form, preserve: true }) ?? [];
|
||||
const tagsProblem = ozonHashtagProblem(
|
||||
usesVariantTags && ownTags.length ? ownTags : profile?.tags ?? []
|
||||
);
|
||||
return (
|
||||
<section className="sku-info-panel" aria-label="规格与发布信息">
|
||||
<div className="sku-panel-heading">
|
||||
@@ -90,7 +98,12 @@ export function SkuInfoPanel({
|
||||
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
|
||||
<Input placeholder="填写 SKU 名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical" rules={[{max: 50, message: '货号最多 50 字符'}]}>
|
||||
<Form.Item
|
||||
name={[fieldName, 'offer_id']}
|
||||
label="货号"
|
||||
layout="vertical"
|
||||
rules={[{ max: 50, message: '货号最多 50 字符' }]}
|
||||
>
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
|
||||
@@ -148,100 +161,130 @@ export function SkuInfoPanel({
|
||||
</>
|
||||
)}
|
||||
<div className="sku-info-group-title">发布文案与类目属性</div>
|
||||
{usesVariantTitle && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="sku-package-label">
|
||||
<span>俄文标题</span>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="sku-package-label">
|
||||
<span>俄文标题</span>
|
||||
{usesVariantTitle ? (
|
||||
<Button type="link" size="small" onClick={onUseCommonTitle}>
|
||||
设为通用
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
layout="vertical"
|
||||
className="sku-info-variant-title"
|
||||
required
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name={[fieldName, 'title']} noStyle rules={[{ max: 500 }]}>
|
||||
<Input placeholder="填写当前 SKU 俄文标题" maxLength={500} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 文案"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'title')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 标题中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'title')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantTags && (
|
||||
<Form.Item label="主题标签" layout="vertical" className="sku-info-variant-tags">
|
||||
<Space.Compact
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
) : (
|
||||
<Typography.Text type="secondary">使用通用标题</Typography.Text>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
layout="vertical"
|
||||
className="sku-info-variant-title"
|
||||
required
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name={usesVariantTitle ? [fieldName, 'title'] : undefined}
|
||||
noStyle
|
||||
rules={usesVariantTitle ? [{ max: 500 }] : undefined}
|
||||
>
|
||||
<Form.Item name={[fieldName, 'tags']} noStyle>
|
||||
<Select
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="留空则继承 SPU 标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 标签"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'tags')}
|
||||
<Input
|
||||
placeholder={
|
||||
usesVariantTitle
|
||||
? '填写当前 SKU 俄文标题'
|
||||
: '请在主要信息中填写通用标题'
|
||||
}
|
||||
maxLength={500}
|
||||
readOnly={!usesVariantTitle}
|
||||
value={usesVariantTitle ? undefined : (profile?.title ?? '')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 标签中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'tags')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantDescription && (
|
||||
<Form.Item
|
||||
label="产品描述"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-description"
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 文案"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'title')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 标题中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'title')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="主题标签"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-tags"
|
||||
validateStatus={tagsProblem ? 'warning' : undefined}
|
||||
help={tagsProblem ?? undefined}
|
||||
extra="以 # 开头,多词用下划线连接;每个最多 30 字符,最多 30 个。"
|
||||
>
|
||||
<Space.Compact
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Space.Compact
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
<Form.Item name={usesVariantTags ? [fieldName, 'tags'] : undefined} noStyle>
|
||||
<Select
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
mode="tags"
|
||||
disabled={!usesVariantTags}
|
||||
value={usesVariantTags ? undefined : (profile?.tags ?? [])}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={
|
||||
usesVariantTags ? '留空则继承 SPU 标签' : '使用通用主题标签'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 标签"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'tags')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 标签中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'tags')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="产品描述" layout="vertical" className="sku-info-variant-description">
|
||||
<Space.Compact
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name={usesVariantDescription ? [fieldName, 'description'] : undefined}
|
||||
noStyle
|
||||
>
|
||||
<Form.Item name={[fieldName, 'description']} noStyle>
|
||||
<Input.TextArea rows={3} placeholder="留空则继承 SPU 描述" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 描述"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'description')}
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
readOnly={!usesVariantDescription}
|
||||
value={
|
||||
usesVariantDescription ? undefined : (profile?.description ?? '')
|
||||
}
|
||||
placeholder={
|
||||
usesVariantDescription ? '留空则继承 SPU 描述' : '使用通用产品描述'
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 描述中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'description')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 描述"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'description')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 描述中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'description')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
{skuInfoAspectDefinitions.length > 0 && sourceId && (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export type SuiteDimensionUnit = 'cm' | 'mm' | 'm';
|
||||
export type SuiteDimensionFields = {
|
||||
length: string;
|
||||
width: string;
|
||||
height: string;
|
||||
unit: SuiteDimensionUnit;
|
||||
};
|
||||
const axes = ['length', 'width', 'height'] as const;
|
||||
const labels = { length: '长', width: '宽', height: '高' };
|
||||
const millimeters = { cm: 10, mm: 1, m: 1000 };
|
||||
const decimal = (value: number) => String(Number(value.toPrecision(12)));
|
||||
export const emptySuiteDimensions = (): SuiteDimensionFields => ({
|
||||
length: '',
|
||||
width: '',
|
||||
height: '',
|
||||
unit: 'cm',
|
||||
});
|
||||
|
||||
/** 切换单位同时换算数值,保持实物尺寸不变,避免切换后误报尺寸。 */
|
||||
export function convertSuiteDimensions(
|
||||
fields: SuiteDimensionFields,
|
||||
unit: SuiteDimensionUnit
|
||||
): SuiteDimensionFields {
|
||||
const next = { ...fields, unit };
|
||||
for (const axis of axes)
|
||||
if (fields[axis])
|
||||
next[axis] = decimal(
|
||||
(Number(fields[axis]) * millimeters[fields.unit]) / millimeters[unit]
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 保持现有任务字符串契约,只序列化用户提供的轴,空轴不推测为零。 */
|
||||
export function serializeSuiteDimensions(fields: SuiteDimensionFields): string {
|
||||
return axes
|
||||
.filter(
|
||||
(axis) =>
|
||||
fields[axis] && Number(fields[axis]) > 0 && Number.isFinite(Number(fields[axis]))
|
||||
)
|
||||
.map((axis) => `${labels[axis]} ${fields[axis]} ${fields.unit}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/** 回填旧任务与采集尺寸时只接受带单位的明确轴或长宽高顺序,不猜测尺码。 */
|
||||
export function parseSuiteDimensions(value: string): SuiteDimensionFields {
|
||||
const next = emptySuiteDimensions();
|
||||
const normalized = value
|
||||
.replace(/毫米/g, 'mm')
|
||||
.replace(/厘米|公分/g, 'cm')
|
||||
.replace(/米/g, 'm');
|
||||
const commonUnit = normalized.match(/(?:\d|\s)(mm|cm|m)\b/i)?.[1]?.toLowerCase() as
|
||||
SuiteDimensionUnit | undefined;
|
||||
const named = /(?:长|length|宽|width|高|height)\s*[::]?\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)?/gi;
|
||||
for (const match of normalized.matchAll(named)) {
|
||||
const axis = /^(长|length)/i.test(match[0])
|
||||
? 'length'
|
||||
: /^(宽|width)/i.test(match[0])
|
||||
? 'width'
|
||||
: 'height';
|
||||
const unit = (match[2]?.toLowerCase() ?? commonUnit) as SuiteDimensionUnit | undefined;
|
||||
if (unit && Number(match[1]) > 0)
|
||||
next[axis] = decimal((Number(match[1]) * millimeters[unit]) / 10);
|
||||
}
|
||||
if (!axes.some((axis) => next[axis])) {
|
||||
const triple = normalized.match(
|
||||
/^\s*(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)\s*$/i
|
||||
);
|
||||
if (triple)
|
||||
for (const [index, axis] of axes.entries())
|
||||
if (Number(triple[index + 1]) > 0)
|
||||
next[axis] = decimal(
|
||||
(Number(triple[index + 1]) *
|
||||
millimeters[triple[4].toLowerCase() as SuiteDimensionUnit]) /
|
||||
10
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ImageSuiteShot } from '@atlas/contracts';
|
||||
|
||||
export const suiteCategories = [
|
||||
{ key: 'white', label: '白底主图' },
|
||||
{ key: 'scene', label: '场景图' },
|
||||
{ key: 'selling', label: '卖点图' },
|
||||
{ key: 'other', label: '其他' },
|
||||
] as const;
|
||||
export const suiteTypeLabels: Record<ImageSuiteShot['type'], string> = {
|
||||
white_background: '白底主图',
|
||||
lifestyle: '场景展示图',
|
||||
selling_point: '核心卖点图',
|
||||
detail: '细节特写图',
|
||||
material: '材质图',
|
||||
feature: '功能说明图',
|
||||
dimensions: '尺寸标注图',
|
||||
comparison: '结构对比图',
|
||||
accessories: '配件清单图',
|
||||
usage: '使用步骤图',
|
||||
custom: '自定义图',
|
||||
};
|
||||
export const suiteStyleOptions = [
|
||||
{ value: 1, label: '北欧极简', prompt: '暖白与浅木色,柔和自然光,留白有序,商品主体突出。' },
|
||||
{ value: 2, label: '清新明亮', prompt: '明亮自然光,低饱和清新配色,画面轻盈,商品颜色准确。' },
|
||||
{ value: 3, label: '高级感深色', prompt: '深灰背景,轮廓光突出材质,构图简洁,营造高级质感。' },
|
||||
{
|
||||
value: 4,
|
||||
label: '暖调生活',
|
||||
prompt: '温暖居家场景,柔和窗光与灯光,展现可信的日常使用情境。',
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: '纯净棚拍',
|
||||
prompt: '纯净背景与均匀柔光,清晰边缘,忠实呈现商品,减少装饰。',
|
||||
},
|
||||
];
|
||||
const shot = (
|
||||
category: ImageSuiteShot['category'],
|
||||
type: ImageSuiteShot['type'],
|
||||
theme: string,
|
||||
description: string,
|
||||
evidence: string
|
||||
): ImageSuiteShot => ({ id: crypto.randomUUID(), category, type, theme, description, evidence });
|
||||
|
||||
/** 默认方案只指定画面用途,未知商品卖点和尺寸留给已填写资料,不制造事实。 */
|
||||
export function defaultSuiteShots(): ImageSuiteShot[] {
|
||||
return [
|
||||
shot(
|
||||
'white',
|
||||
'white_background',
|
||||
'商品完整外观',
|
||||
'纯白背景完整展示当前 SKU,准确保留商品外观与颜色,无文字和装饰。',
|
||||
'主体图、颜色'
|
||||
),
|
||||
shot(
|
||||
'scene',
|
||||
'lifestyle',
|
||||
'日常使用场景',
|
||||
'根据商品资料选择一个真实日常使用场景,商品主体清晰突出。',
|
||||
'商品用途、描述'
|
||||
),
|
||||
shot(
|
||||
'selling',
|
||||
'selling_point',
|
||||
'核心卖点',
|
||||
'从已填写的核心卖点中选择最重要的一点,以清晰图文表达,不添加未知参数。',
|
||||
'核心卖点/要求'
|
||||
),
|
||||
shot(
|
||||
'selling',
|
||||
'detail',
|
||||
'商品细节',
|
||||
'根据参考图特写商品真实结构与做工,仅展示有图片依据的细节。',
|
||||
'参考底图'
|
||||
),
|
||||
];
|
||||
}
|
||||
export function extraSuiteShot(category: ImageSuiteShot['category']): ImageSuiteShot {
|
||||
const type = (
|
||||
{
|
||||
white: 'white_background',
|
||||
scene: 'lifestyle',
|
||||
selling: 'detail',
|
||||
other: 'custom',
|
||||
} as const
|
||||
)[category];
|
||||
return shot(
|
||||
category,
|
||||
type,
|
||||
'补充方案',
|
||||
'补充一个由商品资料或参考图片支持的画面主题。',
|
||||
'请核对商品资料'
|
||||
);
|
||||
}
|
||||
export function suiteTypes(category: ImageSuiteShot['category']): ImageSuiteShot['type'][] {
|
||||
return {
|
||||
white: ['white_background'],
|
||||
scene: ['lifestyle'],
|
||||
selling: ['selling_point', 'detail', 'material', 'feature'],
|
||||
other: ['dimensions', 'comparison', 'accessories', 'usage', 'custom'],
|
||||
}[category] as ImageSuiteShot['type'][];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type SuiteWeightUnit = 'g' | 'kg';
|
||||
export type SuiteWeightFields = { value: string; unit: SuiteWeightUnit };
|
||||
const grams = { g: 1, kg: 1000 };
|
||||
const decimal = (value: number) => String(Number(value.toPrecision(12)));
|
||||
export const emptySuiteWeight = (): SuiteWeightFields => ({ value: '', unit: 'g' });
|
||||
|
||||
/** 重量单位切换只改变显示数值,保持本次任务的实际净重不变。 */
|
||||
export function convertSuiteWeight(
|
||||
fields: SuiteWeightFields,
|
||||
unit: SuiteWeightUnit
|
||||
): SuiteWeightFields {
|
||||
return {
|
||||
unit,
|
||||
value: fields.value
|
||||
? decimal((Number(fields.value) * grams[fields.unit]) / grams[unit])
|
||||
: '',
|
||||
};
|
||||
}
|
||||
export function serializeSuiteWeight(fields: SuiteWeightFields): string {
|
||||
return fields.value && Number.isFinite(Number(fields.value)) && Number(fields.value) > 0
|
||||
? `${fields.value} ${fields.unit}`
|
||||
: '';
|
||||
}
|
||||
|
||||
/** 已保存和采集重量必须带明确单位,默认以克回填,避免把未知单位当成克。 */
|
||||
export function parseSuiteWeight(value: string): SuiteWeightFields {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/千克|公斤/g, 'kg')
|
||||
.replace(/克/g, 'g');
|
||||
const match = normalized.match(
|
||||
/^(?:净重|重量|net\s*weight|weight)?\s*[::]?\s*(\d+(?:\.\d+)?|\.\d+)\s*(kg|g)\s*$/i
|
||||
);
|
||||
if (!match || !Number.isFinite(Number(match[1])) || Number(match[1]) <= 0)
|
||||
return emptySuiteWeight();
|
||||
return {
|
||||
value: decimal(Number(match[1]) * grams[match[2].toLowerCase() as SuiteWeightUnit]),
|
||||
unit: 'g',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CreateImageSuiteRequest, ImageSuite, ImageSuitePlanInput } from '@atlas/contracts';
|
||||
import { api } from './api';
|
||||
|
||||
const path = (productId: string) => `/products/${productId}/ai/image-suites`;
|
||||
const headers = (key: string) => ({ headers: { 'Idempotency-Key': key } });
|
||||
export const imageSuitesApi = {
|
||||
plan: (productId: string, input: ImageSuitePlanInput, key: string) =>
|
||||
api.post<ImageSuite>(`/products/${productId}/ai/suite-plans`, input, headers(key)),
|
||||
generate: (productId: string, input: CreateImageSuiteRequest, key: string) =>
|
||||
api.post<ImageSuite>(path(productId), input, headers(key)),
|
||||
read: (productId: string, id: string) => api.get<ImageSuite>(`${path(productId)}/${id}`),
|
||||
list: (productId: string, cursor?: string) =>
|
||||
api.getList<ImageSuite, { next_cursor: string | null; has_more: boolean }>(
|
||||
`${path(productId)}?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`
|
||||
),
|
||||
cancel: (productId: string, id: string) =>
|
||||
api.post<ImageSuite>(`${path(productId)}/${id}/cancel`, {}, headers(crypto.randomUUID())),
|
||||
retry: (productId: string, id: string, key: string) =>
|
||||
api.post<ImageSuite>(`${path(productId)}/${id}/retry`, {}, headers(key)),
|
||||
};
|
||||
Reference in New Issue
Block a user