feat(edit): 编辑页上报ozon 开发

This commit is contained in:
R524809
2026-09-30 15:57:54 +08:00
parent 0a303414d8
commit 45871584b3
48 changed files with 3700 additions and 887 deletions
@@ -3,6 +3,13 @@ import type { InputProps } from 'antd';
const digitsOnly = /^\d+$/;
/** 仅去掉数值本身为整数的历史小数尾零;非整数原值留给用户核对。 */
export function normalizePackageInteger(value: string | null | undefined): string {
const text = value ?? '';
if (!/^\d+(?:\.0+)?$/.test(text)) return text;
return text.replace(/\.0+$/, '').replace(/^0+(?=\d)/, '');
}
export function isPositivePackageInteger(value: unknown): value is string {
return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0;
}
@@ -30,7 +30,7 @@ import type {
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { PackageIntegerInput } from '@/components/PackageIntegerInput';
import { PackageIntegerInput, normalizePackageInteger } from '@/components/PackageIntegerInput';
import { percentToRate, rateToPercent } from './pricingPercentage';
import { pricingInputProblem } from './pricingInputValidation';
@@ -326,7 +326,16 @@ export function PricingCalculator({
const [policyName, setPolicyName] = useState<string | null>(null);
const [policySaving, setPolicySaving] = useState(false);
const [localCatalog, setLocalCatalog] = useState(catalog);
const [input, setInput] = useState(() => structuredClone(initial));
const [input, setInput] = useState(() => ({
...structuredClone(initial),
package: {
...initial.package,
shipping_weight_g: normalizePackageInteger(initial.package.shipping_weight_g),
length_mm: normalizePackageInteger(initial.package.length_mm),
width_mm: normalizePackageInteger(initial.package.width_mm),
height_mm: normalizePackageInteger(initial.package.height_mm),
},
}));
const [preview, setPreview] = useState<PricingPreview | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState('');
+13 -5
View File
@@ -14,6 +14,7 @@ import type {
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { normalizePackageInteger } from '@/components/PackageIntegerInput';
import { defaultPricing, PricingCalculator } from './PricingCalculator';
import { pricingInputProblem } from './pricingInputValidation';
type Values = SaveProductEditorDraftRequest;
@@ -27,10 +28,10 @@ export function skuPricingInput(
base = values.common.default_package_spec,
pkg = v.package_spec;
const effectivePackage = {
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
length_mm: pkg.length_mm ?? base.length_mm ?? '',
width_mm: pkg.width_mm ?? base.width_mm ?? '',
height_mm: pkg.height_mm ?? base.height_mm ?? '',
shipping_weight_g: normalizePackageInteger(pkg.shipping_weight_g ?? base.shipping_weight_g),
length_mm: normalizePackageInteger(pkg.length_mm ?? base.length_mm),
width_mm: normalizePackageInteger(pkg.width_mm ?? base.width_mm),
height_mm: normalizePackageInteger(pkg.height_mm ?? base.height_mm),
};
return {
store_id: values.profile.target_store_id,
@@ -538,7 +539,14 @@ export function SkuPricing({
]);
setModal({
...modal,
package: { ...base, status: base.status },
package: {
...base,
shipping_weight_g: normalizePackageInteger(base.shipping_weight_g),
length_mm: normalizePackageInteger(base.length_mm),
width_mm: normalizePackageInteger(base.width_mm),
height_mm: normalizePackageInteger(base.height_mm),
status: base.status,
},
});
}}
>
@@ -47,6 +47,88 @@
gap: 8px;
white-space: nowrap;
}
.editor-publish-status-slot:not(:empty) {
margin-top: 14px;
}
.editor-publish-status {
display: flex;
width: 100%;
min-height: 52px;
align-items: center;
gap: 12px;
padding: 10px 14px;
border: 1px solid var(--publish-status-border);
border-radius: 10px;
background: var(--publish-status-background);
color: #344054;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s;
}
.editor-publish-status:hover {
border-color: var(--publish-status-accent);
box-shadow: 0 2px 8px rgba(16, 24, 40, 0.06);
}
.editor-publish-status:focus-visible {
outline: 2px solid var(--publish-status-accent);
outline-offset: 2px;
}
.editor-publish-status--progress {
--publish-status-border: #d9d6f6;
--publish-status-background: #f7f6ff;
--publish-status-accent: #7c52e8;
}
.editor-publish-status--success {
--publish-status-border: #cdebd5;
--publish-status-background: #f5fbf6;
--publish-status-accent: #389e55;
}
.editor-publish-status--warning {
--publish-status-border: #f5e2b9;
--publish-status-background: #fffaf0;
--publish-status-accent: #ad6800;
}
.editor-publish-status--error {
--publish-status-border: #f2cccc;
--publish-status-background: #fff7f7;
--publish-status-accent: #cf3d3d;
}
.editor-publish-status-icon {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
border-radius: 8px;
background: #fff;
color: var(--publish-status-accent);
font-size: 16px;
}
.editor-publish-status-content {
display: flex;
min-width: 0;
flex: 1;
align-items: baseline;
gap: 10px;
}
.editor-publish-status-title {
color: #252339;
font-weight: 600;
}
.editor-publish-status-count {
color: #73718a;
font-size: 13px;
}
.editor-publish-status-link {
flex: 0 0 auto;
color: var(--publish-status-accent);
font-size: 13px;
font-weight: 500;
}
.editor-publish-status-link .anticon {
margin-left: 3px;
font-size: 11px;
}
/* 工作区:左侧主编辑列 + 右侧采集信息面板(见 CollectedProductInfoPanel.css) */
.editor-workspace {
@@ -299,6 +381,10 @@
}
}
@media (max-width: 560px) {
.editor-publish-status-content {
flex-wrap: wrap;
gap: 0 8px;
}
.product-editor {
padding-bottom: 230px;
}
+150 -48
View File
@@ -27,16 +27,18 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { productEditorApi } from '@/services/productEditor';
import { usePlatform } from '@/services/platform';
import { collectionApi } from '@/services/collection';
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
import { isPositivePackageInteger, normalizePackageInteger } from '@/components/PackageIntegerInput';
import {
CollectedProductInfoPanel,
type CollectedProductSummary,
} from './components/CollectedProductInfoPanel';
import { ProductAttributeSection } from './components/ProductAttributeSection';
import { ProductPublishActions } from './components/ProductPublishActions';
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
import { ProductOtherSection } from './components/ProductOtherSection';
import { ProductVariantSection } from './components/ProductVariantSection';
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
import {
ProductCopyComparisonModal,
SkuCopyAiModal,
@@ -97,7 +99,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
common: {
...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec,
default_package_spec: normalizedPackageSpec(snapshot.product.default_package_spec),
package_weight_mode: snapshot.product.package_weight_mode,
package_dimensions_mode: snapshot.product.package_dimensions_mode,
},
@@ -107,7 +109,8 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
platformCode.toUpperCase() === 'OZON' && snapshot.profile.fulfillment === 'unknown'
? 'rfbs'
: (snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment']),
title: snapshot.profile.title,
title: snapshot.profile.title_mode === 'variant' ? null : snapshot.profile.title,
title_mode: snapshot.profile.title_mode,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
platform_product_category_id: snapshot.profile.category.id,
@@ -141,7 +144,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
tags: variant.tags,
description: variant.description,
physical_spec: variant.physical_spec,
package_spec: variant.package_spec,
package_spec: normalizedPackageSpec(variant.package_spec),
offer_id: variant.listing.offer_id,
media_asset_ids: variant.media.map((media) => media.asset_id),
})),
@@ -153,6 +156,55 @@ function nullable(value: string | null | undefined) {
return trimmed || null;
}
function normalizedPackageSpec<
T extends {
length_mm: string | null;
width_mm: string | null;
height_mm: string | null;
shipping_weight_g: string | null;
},
>(spec: T): T {
const integerOrOriginal = (value: string | null) =>
nullable(normalizePackageInteger(value?.trim()));
return {
...spec,
length_mm: integerOrOriginal(spec.length_mm),
width_mm: integerOrOriginal(spec.width_mm),
height_mm: integerOrOriginal(spec.height_mm),
shipping_weight_g: integerOrOriginal(spec.shipping_weight_g),
};
}
/** 草稿可缺少包装规格,但已填写的重量和尺寸必须是正整数。 */
function packageIntegerProblem(values: EditorFormValues): string | null {
const check = (
spec: Pick<
EditorFormValues['common']['default_package_spec'],
'shipping_weight_g' | 'length_mm' | 'width_mm' | 'height_mm'
>,
owner: string
) => {
for (const [key, label] of [
['shipping_weight_g', '包装重量'],
['length_mm', '包装长度'],
['width_mm', '包装宽度'],
['height_mm', '包装高度'],
] as const) {
const value = spec[key];
if (value !== null && value !== '' && !isPositivePackageInteger(value))
return `${owner}的${label}请输入大于 0 的整数`;
}
return null;
};
const commonProblem = check(values.common.default_package_spec, '商品');
if (commonProblem) return commonProblem;
for (const [index, variant] of values.variants.entries()) {
const problem = check(variant.package_spec, variant.display_name || `SKU ${index + 1}`);
if (problem) return problem;
}
return null;
}
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
@@ -229,23 +281,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
height_mm: nullable(spec.height_mm),
net_weight_g: nullable(spec.net_weight_g),
});
const packageSpec = <
T extends {
length_mm: string | null;
width_mm: string | null;
height_mm: string | null;
shipping_weight_g: string | null;
},
>(
spec: T
): T => ({
...spec,
length_mm: nullable(spec.length_mm),
width_mm: nullable(spec.width_mm),
height_mm: nullable(spec.height_mm),
shipping_weight_g: nullable(spec.shipping_weight_g),
});
const commonPackage = packageSpec(values.common.default_package_spec);
const commonPackage = normalizedPackageSpec(values.common.default_package_spec);
const weightByVariant = values.common.package_weight_mode === 'variant';
const dimensionsByVariant = values.common.package_dimensions_mode === 'variant';
const packageComplete = (
@@ -278,6 +314,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
profile: {
...values.profile,
title: nullable(values.profile.title),
title_mode: values.profile.title_mode ?? 'common',
description: nullable(values.profile.description),
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
},
@@ -288,7 +325,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
}),
variants: values.variants.map((variant) => {
const normalizedPackage = packageSpec(variant.package_spec);
const normalizedPackage = normalizedPackageSpec(variant.package_spec);
if (weightByVariant || dimensionsByVariant) {
const effectivePackage = {
...normalizedPackage,
@@ -359,7 +396,7 @@ function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSumma
};
return {
price: facts.price ? `${facts.price.amount} ${facts.price.currency_code}` : '—',
brand: facts.brand ?? find((label) => /brand|品牌/.test(label)),
brand: displayBrandName(facts.brand ?? find((label) => /brand|品牌/.test(label))),
physicalDimensions: dimensions(dimension(false)),
physicalWeight: grams(weight(false)),
packageDimensions: dimensions(dimension(true)),
@@ -458,6 +495,7 @@ const ProductEditPage = () => {
const [saving, setSaving] = useState(false);
const [savingSourceDescription, setSavingSourceDescription] = useState(false);
const [dirty, setDirty] = useState(false);
const [publishStatusContainer, setPublishStatusContainer] = useState<HTMLDivElement | null>(null);
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
@@ -497,10 +535,9 @@ const ProductEditPage = () => {
: ([] as ProductAttributeSchema[]),
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
);
// 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。
const editableAttributeSchema = useMemo(
() => activeAttributeSchema.filter((attribute) => !attribute.editor_field),
[activeAttributeSchema]
// 内建品牌字段同样使用当前类目的 Ozon 字典,其余内建字段无需加载字典。
const brandDefinition = activeAttributeSchema.find(
(attribute) => attribute.editor_field === 'brand'
);
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
@@ -519,7 +556,7 @@ const ProductEditPage = () => {
setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
setUsesVariantTitle(next.profile.title_mode === 'variant');
setCopyTranslations(next.copy_translations);
setDirty(false);
} catch (error) {
@@ -637,13 +674,16 @@ const ProductEditPage = () => {
selectedCategoryId,
snapshot,
]);
// 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。
// 预取类目属性字典;品牌初始只查询 Ozon 的无品牌候选,其他品牌按关键词加载。
useEffect(() => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
const attributeIds = [
...new Set(
editableAttributeSchema
.filter((attribute) => !!attribute.dictionary_id)
activeAttributeSchema
.filter((attribute) =>
!!attribute.dictionary_id &&
(!attribute.editor_field || attribute.editor_field === 'brand')
)
.map((attribute) => attribute.attribute_id)
),
];
@@ -662,7 +702,9 @@ const ProductEditPage = () => {
snapshot.profile.id,
currentPlatform.id,
attributeId,
undefined,
attributeId === brandDefinition?.attribute_id
? OZON_NO_BRAND_VALUE
: undefined,
selectedCategoryId
),
] as const
@@ -698,7 +740,7 @@ const ProductEditPage = () => {
return () => {
active = false;
};
}, [catalogRefresh, currentPlatform, editableAttributeSchema, selectedCategoryId, snapshot]);
}, [catalogRefresh, currentPlatform, activeAttributeSchema, selectedCategoryId, snapshot]);
useEffect(() => {
if (!snapshot || !currentPlatform) return;
let active = true;
@@ -821,30 +863,63 @@ const ProductEditPage = () => {
const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant');
const useCommonPackageWeight = () => confirmCommonPackageMode('weight');
const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions');
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
/** SKU 标签和描述继续按覆盖值继承商品通用文案。 */
const useVariantTags = () => setUsesVariantTags(true);
const useVariantDescription = () => setUsesVariantDescription(true);
const useVariantTitle = () => setUsesVariantTitle(true);
/** 俄文标题模式切换时清空退出模式的值,避免隐藏文案继续参与发布。 */
const changeTitleMode = (mode: 'common' | 'variant') => {
if (mode === 'variant') {
form.setFieldValue(['profile', 'title'], null);
setCopyTranslations((current) =>
current.filter((item) => item.field !== 'title' || item.variant_id !== null)
);
} else {
const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants'];
variants.forEach((_, index) => form.setFieldValue(['variants', index, 'title'], null));
setCopyTranslations((current) =>
current.filter((item) => item.field !== 'title' || item.variant_id === null)
);
}
form.setFieldValue(['profile', 'title_mode'], mode);
setUsesVariantTitle(mode === 'variant');
setDirty(true);
};
const useVariantTitle = () => changeTitleMode('variant');
const useCommonTitle = () =>
modal.confirm({
title: '将俄文标题设为通用?',
content: '确认后将清空所有 SKU 的俄文标题,并恢复商品通用俄文标题的编辑。',
okText: '确认调整',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => changeTitleMode('common'),
});
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
const save = async (values: EditorFormValues) => {
values = { ...values, ...form.getFieldsValue(true) };
if (!productId || !currentPlatform || !snapshot) return false;
const draft = normalizedValues({
...values,
ai_source_material: sourceDraft ?? snapshot.source_material,
ai_source_overridden_fields: sourceOverriddenFields,
copy_translations: copyTranslations.filter(
(item) =>
!item.variant_id ||
values.variants.some((variant) => variant.id === item.variant_id)
),
});
const invalidPackage = packageIntegerProblem(draft);
if (invalidPackage) {
message.error(invalidPackage);
return false;
}
setSaving(true);
try {
await productEditorApi.save(
productId,
currentPlatform.id,
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
normalizedValues({
...values,
ai_source_material: sourceDraft ?? snapshot.source_material,
ai_source_overridden_fields: sourceOverriddenFields,
copy_translations: copyTranslations.filter(
(item) =>
!item.variant_id ||
values.variants.some((variant) => variant.id === item.variant_id)
),
})
draft
);
message.success('草稿已保存');
setDirty(false);
@@ -996,7 +1071,7 @@ const ProductEditPage = () => {
variants[index] = { ...variants[index], offer_id: offer.offer_id };
}
const copy = result.spu_copy;
if (copy?.title && !profile.title) {
if (copy?.title && profile.title_mode !== 'variant' && !profile.title) {
profile.title = copy.title.ru;
addCopyTranslations(null, { title: copy.title }, taskId);
}
@@ -1082,6 +1157,7 @@ const ProductEditPage = () => {
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
if (index < 0) return;
const copy = skuCopyResult;
if (copy.title && !usesVariantTitle) changeTitleMode('variant');
form.setFieldValue(
'variants',
variants.map((variant, current) =>
@@ -1098,7 +1174,6 @@ const ProductEditPage = () => {
// SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。
if (skuCopyTaskId.current)
addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current);
setUsesVariantTitle(true);
setUsesVariantTags(true);
setUsesVariantDescription(true);
setDirty(true);
@@ -1406,6 +1481,7 @@ const ProductEditPage = () => {
</Tag>
</div>
</div>
<div ref={setPublishStatusContainer} className="editor-publish-status-slot" />
</header>
{snapshot.warnings.map((warning) => (
<Alert key={warning.code} type="warning" message={warning.message} showIcon />
@@ -1436,11 +1512,21 @@ const ProductEditPage = () => {
usesVariantPackageDimensions={usesVariantPackageDimensions}
usesVariantTitle={usesVariantTitle}
onUseVariantTitle={useVariantTitle}
onUseCommonTitle={useCommonTitle}
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
onUseVariantPackageWeight={useVariantPackageWeight}
onUseVariantPackageDimensions={useVariantPackageDimensions}
onUseCommonPackageWeight={useCommonPackageWeight}
onUseCommonPackageDimensions={useCommonPackageDimensions}
brandDefinition={brandDefinition}
brandOptions={brandDefinition ? dictionaryValues[brandDefinition.attribute_id] ?? [] : []}
onOpenBrandOptions={(attributeId) =>
void loadDictionaryValues(attributeId, OZON_NO_BRAND_VALUE)
}
onSearchBrandOptions={(attributeId, query) =>
void loadDictionaryValues(attributeId, query)
}
onLoadMoreBrandOptions={loadMoreDictionaryValues}
/>
<ProductAttributeSection
@@ -1488,6 +1574,7 @@ const ProductEditPage = () => {
usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription}
usesVariantTitle={usesVariantTitle}
onUseCommonTitle={useCommonTitle}
onAiCopy={(variantId) => {
setSkuCopyVariantId(variantId);
setSkuCopyResult(null);
@@ -1495,6 +1582,7 @@ const ProductEditPage = () => {
skuCopyTaskId.current = null;
}}
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
onAttributeChanged={() => setDirty(true)}
onImagesChanged={() => setDirty(true)}
/>
@@ -1539,13 +1627,27 @@ const ProductEditPage = () => {
<Button onClick={leave}>取消并关闭</Button>
<Button
type="primary"
ghost
htmlType="submit"
loading={saving}
icon={<SaveOutlined />}
>
保存草稿
</Button>
<Button disabled>选择 SKU 并首次上架</Button>
<ProductPublishActions
snapshot={snapshot}
platformId={currentPlatform.id}
disabled={saving || aiAutofillLoading}
statusContainer={publishStatusContainer}
ensureSaved={async () => {
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return null;
return productEditorApi.read(snapshot.product.id, currentPlatform.id);
}}
onStatusChange={async () => {
const next = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
setSnapshot(current => current ? {...current, profile: {...current.profile, listing_summary: next.profile.listing_summary}, variants: current.variants.map(v => ({...v, listing: next.variants.find(n => n.id === v.id)?.listing ?? v.listing}))} : current);
}}
/>
</Space>
</footer>
<ProductCopyComparisonModal
@@ -0,0 +1,6 @@
/** Ozon 品牌字典的无品牌值只在界面翻译,草稿与发布继续使用原始候选值。 */
export const OZON_NO_BRAND_VALUE = 'Нет бренда';
export function displayBrandName(value: string): string {
return value === OZON_NO_BRAND_VALUE ? '无品牌' : value;
}
@@ -212,8 +212,12 @@
padding: 0 6px;
background: rgba(255, 255, 255, 0.94);
font-size: 11px;
gap: 3px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.collected-image-edit .anticon-down {
font-size: 10px;
}
@container (max-width: 1099px) {
/* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */
.editor-aside {
@@ -10,7 +10,6 @@ import type {
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
type NamePath = Array<string | number>;
const productColorValueLimit = 100;
/**
@@ -118,6 +117,7 @@ type Props = {
itemLayout?: 'horizontal' | 'vertical';
forceRequiredNames?: string[];
colorNameDefinition?: ProductAttributeSchema;
onAttributeChanged?: () => void;
};
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
@@ -152,6 +152,7 @@ function DynamicAttributeField({
scope,
variantId,
colorNameDefinition,
onAttributeChanged,
}: {
definition: ProductAttributeSchema;
index: number;
@@ -167,6 +168,7 @@ function DynamicAttributeField({
scope: Props['scope'];
variantId: Props['variantId'];
colorNameDefinition: Props['colorNameDefinition'];
onAttributeChanged: Props['onAttributeChanged'];
}) {
const [dictionaryQuery, setDictionaryQuery] = useState('');
const onSyncRef = useRef(onSyncDictionaryValues);
@@ -181,12 +183,25 @@ function DynamicAttributeField({
return () => window.clearTimeout(timer);
}, [definition.attribute_id, definition.dictionary_id, dictionaryQuery]);
const valueName = ['attributes', index, 'values'] as NamePath;
const scalarValueName = [...valueName, 0] as NamePath;
const selectedProductColorValue = Form.useWatch(
['attributes', index, 'values'],
form
) as unknown;
const valueName: ['attributes', number, 'values'] = ['attributes', index, 'values'];
const scalarValueName: ['attributes', number, 'values', 0] = [
'attributes',
index,
'values',
0,
];
// SKU 卡片位于 Form.List('variants') 内,命名 Form.Item 会自动附加 variants 前缀。
const directVariantBinding = scope === 'variant';
const watchedValues = Form.useWatch(valueName, { form, preserve: true }) as unknown;
const attributeValues = Array.isArray(watchedValues)
? watchedValues.filter((value): value is string => typeof value === 'string')
: typeof watchedValues === 'string'
? [watchedValues]
: [];
const setVariantValues = (values: string[]) => {
form.setFieldValue(valueName, values);
onAttributeChanged?.();
};
const lastColorSelection = useRef<string | null>(null);
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
const isRequired =
@@ -204,11 +219,7 @@ function DynamicAttributeField({
),
}))
: [];
const selectedColorValueIds = Array.isArray(selectedProductColorValue)
? selectedProductColorValue.filter((value): value is string => typeof value === 'string')
: typeof selectedProductColorValue === 'string'
? [selectedProductColorValue]
: [];
const selectedColorValueIds = attributeValues;
const colorSelectionKey = selectedColorValueIds.join('\u0000');
/** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */
const syncColorName = (selectedValue: unknown) => {
@@ -254,16 +265,32 @@ function DynamicAttributeField({
const input =
definition.value_type === 'dictionary' ? (
<Form.Item name={allowsMultipleValues ? valueName : scalarValueName} noStyle>
<Form.Item
name={directVariantBinding ? undefined : allowsMultipleValues ? valueName : scalarValueName}
noStyle
>
<Select
className={isProductColor(definition) ? 'product-color-select' : undefined}
mode={allowsMultipleValues ? 'multiple' : undefined}
maxCount={allowsMultipleValues ? maxValueCount : undefined}
options={dictionaryOptions}
value={
directVariantBinding
? allowsMultipleValues
? attributeValues
: attributeValues[0]
: undefined
}
showSearch
allowClear
filterOption={false}
onChange={(value) => syncColorName(value)}
onChange={(value) => {
if (directVariantBinding)
setVariantValues(
Array.isArray(value) ? value : value ? [value] : []
);
syncColorName(value);
}}
onOpenChange={(open) => {
if (!open || !definition.dictionary_id) return;
setDictionaryQuery('');
@@ -313,8 +340,14 @@ function DynamicAttributeField({
/>
</Form.Item>
) : definition.value_type === 'boolean' ? (
<Form.Item name={scalarValueName} noStyle>
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
<Select
value={directVariantBinding ? attributeValues[0] : undefined}
onChange={
directVariantBinding
? (value) => setVariantValues(value ? [value] : [])
: undefined
}
options={[
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
@@ -323,25 +356,42 @@ function DynamicAttributeField({
/>
</Form.Item>
) : allowsMultipleValues ? (
<Form.Item name={valueName} noStyle>
<Form.Item name={directVariantBinding ? undefined : valueName} noStyle>
<Select
mode="tags"
value={directVariantBinding ? attributeValues : undefined}
onChange={directVariantBinding ? setVariantValues : undefined}
maxCount={maxValueCount}
tokenSeparators={[',']}
placeholder={`输入后回车添加,最多 ${maxValueCount} 项`}
/>
</Form.Item>
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
<Form.Item name={scalarValueName} noStyle>
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
<InputNumber<string>
value={directVariantBinding ? attributeValues[0] ?? null : undefined}
onChange={
directVariantBinding
? (value) => setVariantValues(value === null ? [] : [value])
: undefined
}
step={definition.value_type === 'integer' ? '1' : '0.001'}
stringMode
placeholder={`填写${definition.name}`}
/>
</Form.Item>
) : (
<Form.Item name={scalarValueName} noStyle>
<Input placeholder={`填写${definition.name}`} />
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
<Input
value={directVariantBinding ? attributeValues[0] ?? '' : undefined}
onChange={
directVariantBinding
? (event) =>
setVariantValues(event.target.value ? [event.target.value] : [])
: undefined
}
placeholder={`填写${definition.name}`}
/>
</Form.Item>
);
@@ -384,6 +434,7 @@ export function DynamicAttributeFields({
itemLayout,
forceRequiredNames,
colorNameDefinition,
onAttributeChanged,
}: Props) {
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
const fields = definitions.flatMap((definition) => {
@@ -413,6 +464,7 @@ export function DynamicAttributeFields({
scope={scope}
variantId={variantId}
colorNameDefinition={colorNameDefinition}
onAttributeChanged={onAttributeChanged}
/>
))}
</div>
@@ -1,8 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Card, Cascader, Form, Input, Radio, Select, Tooltip } from 'antd';
import type { FormInstance } from 'antd';
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot } from '@atlas/contracts';
import { TranslationOutlined } from '@ant-design/icons';
import type {
ProductAttributeDictionaryValue,
ProductAttributeSchema,
ProductEditorSnapshot,
} from '@atlas/contracts';
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
import { displayBrandName, OZON_NO_BRAND_VALUE } from '../brandDisplay';
import { SectionTitle } from './SectionTitle';
import './ProductMainInfoSection.css';
@@ -110,7 +116,13 @@ type Props = {
onUseCommonPackageDimensions: () => void;
usesVariantTitle: boolean;
onUseVariantTitle: () => void;
onUseCommonTitle: () => void;
onCompareTitle: () => void;
brandDefinition: ProductAttributeSchema | undefined;
brandOptions: ProductAttributeDictionaryValue[];
onOpenBrandOptions: (attributeId: string) => void;
onSearchBrandOptions: (attributeId: string, query: string) => void;
onLoadMoreBrandOptions: (attributeId: string, query: string) => void;
};
/**
@@ -136,8 +148,36 @@ export function ProductMainInfoSection({
onUseCommonPackageDimensions,
usesVariantTitle,
onUseVariantTitle,
onUseCommonTitle,
onCompareTitle,
brandDefinition,
brandOptions,
onOpenBrandOptions,
onSearchBrandOptions,
onLoadMoreBrandOptions,
}: Props) {
const publishingLocked = snapshot.variants.some(v => ['imported', 'submitting'].includes(v.listing.status));
const [brandQuery, setBrandQuery] = useState('');
const onSearchBrandOptionsRef = useRef(onSearchBrandOptions);
onSearchBrandOptionsRef.current = onSearchBrandOptions;
// 中文“无品牌”仅用于展示与搜索,表单始终保存用户选中的 Ozon 候选名称。
const brandSearchQuery =
brandQuery.trim() === '无品牌' ? OZON_NO_BRAND_VALUE : brandQuery.trim();
const visibleBrandOptions = brandOptions.filter((option) =>
brandSearchQuery
? option.value.toLocaleLowerCase().includes(brandSearchQuery.toLocaleLowerCase())
: option.value === OZON_NO_BRAND_VALUE
);
useEffect(() => {
if (!brandDefinition?.dictionary_id || !brandSearchQuery) return;
const timer = window.setTimeout(
() => onSearchBrandOptionsRef.current(brandDefinition.attribute_id, brandSearchQuery),
400
);
return () => window.clearTimeout(timer);
}, [brandDefinition?.attribute_id, brandDefinition?.dictionary_id, brandSearchQuery]);
const usesBrandDictionary =
brandDefinition?.value_type === 'dictionary' && !!brandDefinition.dictionary_id;
return (
<Card className="editor-section editor-horizontal-section" bordered={false}>
<SectionTitle
@@ -154,6 +194,7 @@ export function ProductMainInfoSection({
>
<Select
className="editor-standard-select"
disabled={snapshot.variants.some(v => v.listing.status === 'submitting')}
options={snapshot.profile.available_target_stores.map((store) => ({
value: store.id,
label: `${store.name}(${store.currency_code})`,
@@ -227,30 +268,33 @@ export function ProductMainInfoSection({
>
<Input placeholder="填写中文商品名" />
</Form.Item>
<Form.Item label="俄文发布标题" className="span-full" required>
<Form.Item label="俄文标题" className="span-full" required={!usesVariantTitle}>
<div className="profile-override-control">
<Form.Item
name={['profile', 'title']}
noStyle
rules={[{ max: 1000 }]}
rules={[{ max: 500 }]}
>
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
<Input
placeholder="AI 填报或手动填写俄文标题"
maxLength={500}
disabled={usesVariantTitle}
/>
</Form.Item>
{!usesVariantTitle && (
<Tooltip title="在 SKU 中分别编辑标题">
<Button
type="text"
icon={<PlusCircleOutlined />}
aria-label="在 SKU 中分别编辑标题"
onClick={onUseVariantTitle}
/>
</Tooltip>
)}
<Button
type="link"
className="package-mode-button"
aria-label={`${usesVariantTitle ? '设为通用' : '按 SKU 设置'}俄文标题`}
onClick={usesVariantTitle ? onUseCommonTitle : onUseVariantTitle}
>
{usesVariantTitle ? '设为通用' : '按 SKU 设置'}
</Button>
<Tooltip title="查看标题中文对照">
<Button
type="text"
icon={<TranslationOutlined />}
aria-label="查看标题中文对照"
disabled={usesVariantTitle}
onClick={onCompareTitle}
/>
</Tooltip>
@@ -260,15 +304,62 @@ export function ProductMainInfoSection({
name={['common', 'model']}
label="型号"
required
extra={publishingLocked ? "已有 SKU 导入或正在导入,型号已锁定" : undefined}
>
<Input className="editor-short-input" placeholder="首次上架前需填写" />
<Input className="editor-short-input" disabled={publishingLocked} placeholder="首次上架前需填写" />
</Form.Item>
<Form.Item
name={['common', 'brand']}
label="品牌"
required
extra={
usesBrandDictionary
? '默认显示“无品牌”;输入关键词搜索其他品牌。'
: undefined
}
>
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
{usesBrandDictionary ? (
<Select
className="editor-standard-select editor-short-input"
showSearch
allowClear
filterOption={false}
labelRender={({ value, label }) =>
value === OZON_NO_BRAND_VALUE ? '无品牌' : label
}
options={visibleBrandOptions.map((option) => ({
value: option.value,
label: displayBrandName(option.value),
key: option.value_id,
}))}
placeholder="选择 Ozon 品牌,输入名称搜索"
notFoundContent={
brandQuery.trim()
? '暂无匹配的 Ozon 品牌候选'
: '暂无无品牌候选,输入关键词搜索其他品牌'
}
onOpenChange={(open) => {
if (!open) return;
setBrandQuery('');
onOpenBrandOptions(brandDefinition.attribute_id);
}}
onSearch={(query) => {
setBrandQuery(query);
if (!query.trim())
onOpenBrandOptions(brandDefinition.attribute_id);
}}
onPopupScroll={(event) => {
const target = event.currentTarget;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
onLoadMoreBrandOptions(
brandDefinition.attribute_id,
brandSearchQuery
);
}}
/>
) : (
<Input className="editor-short-input" placeholder="填写品牌" />
)}
</Form.Item>
</div>
<PackageFields
@@ -0,0 +1,485 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
import {
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
RightOutlined,
} from '@ant-design/icons';
import type {
ProductEditorSnapshot,
ProductPublishJob,
ProductPublishPreflight,
} from '@atlas/contracts';
import { productEditorApi } from '@/services/productEditor';
import { apiErrorMessage } from '@/services/api';
type Props = {
snapshot: ProductEditorSnapshot;
platformId: string;
disabled: boolean;
statusContainer: HTMLElement | null;
ensureSaved: () => Promise<ProductEditorSnapshot | null>;
onStatusChange: () => Promise<void>;
};
const terminal = (job: ProductPublishJob) =>
['succeeded', 'partially_succeeded', 'failed'].includes(job.status);
const itemLabels: Record<string, string> = {
queued: '排队中',
submitting: '提交中',
polling: '等待 Ozon',
imported: '已导入',
failed: '失败',
skipped: '已跳过',
reconciling: '等待对账',
};
const jobLabels: Record<string, string> = {
queued: '排队中',
running: '处理中',
succeeded: '已导入',
partially_succeeded: '部分已导入',
failed: '未导入',
reconciling: '等待对账',
};
/** 两个发布入口共用保存、指定 SKU 预检、确认与可恢复任务状态。 */
export function ProductPublishActions({
snapshot,
platformId,
disabled,
statusContainer,
ensureSaved,
onStatusChange,
}: Props) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const [stage, setStage] = useState<'select' | 'check' | 'confirm' | 'result'>('select');
const [saved, setSaved] = useState(snapshot);
const [ids, setIds] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [preflight, setPreflight] = useState<ProductPublishPreflight | null>(null);
const [error, setError] = useState('');
const [job, setJob] = useState<ProductPublishJob | null>(null);
const requestKey = useRef<string | null>(null);
const run = useRef(0);
const statusChanged = useRef(onStatusChange);
statusChanged.current = onStatusChange;
useEffect(() => {
let cancelled = false;
setJob(null);
productEditorApi
.publishJobs(snapshot.profile.id, platformId)
.then((jobs) => {
if (!cancelled) setJob(jobs.find((j) => !terminal(j)) ?? jobs[0] ?? null);
})
.catch(() => {});
return () => {
cancelled = true;
run.current++;
};
}, [snapshot.profile.id, platformId]);
useEffect(() => {
if (!job || terminal(job)) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
try {
const next = await productEditorApi.publishJob(job.id, platformId);
if (cancelled) return;
setJob(next);
await statusChanged.current();
if (terminal(next)) return;
} catch (e) {
if (!cancelled) setError(apiErrorMessage(e));
}
if (!cancelled) timer = setTimeout(() => void poll(), 5000);
};
timer = setTimeout(() => void poll(), 2000);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [job?.id, job?.status, platformId]);
const eligible = (data: ProductEditorSnapshot) =>
data.variants.filter((v) => ['not_listed', 'failed'].includes(v.listing.status));
const skipReason = (status: string) =>
status === 'imported'
? '已有成功导入记录,不能重复提交'
: '正在提交或等待对账,不能重复排队';
const check = async (data: ProductEditorSnapshot, selected: string[]) => {
if (!selected.length) {
message.warning('请至少选择 1 个待上架 SKU');
return;
}
if (selected.length > 50) {
message.warning('本次最多上架 50 个 SKU,请使用部分上架分批提交');
return;
}
const thisRun = ++run.current;
setStage('check');
setBusy(true);
setError('');
setPreflight(null);
requestKey.current = null;
try {
for (let attempt = 0; attempt < 6; attempt++) {
const result = await productEditorApi.publishPreflight(
data.profile.id,
platformId,
selected
);
if (thisRun !== run.current) return;
setPreflight(result);
if (result.status !== 'refreshing') {
setStage(result.status === 'ready' ? 'confirm' : 'check');
return;
}
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3000));
}
} catch (e) {
if (thisRun === run.current) setError(apiErrorMessage(e));
} finally {
if (thisRun === run.current) setBusy(false);
}
};
const start = async (partial: boolean) => {
setBusy(true);
setError('');
try {
const data = await ensureSaved();
if (!data) return;
setSaved(data);
setPreflight(null);
requestKey.current = null;
const all = eligible(data).map((v) => v.id);
setIds(partial ? [] : all);
setOpen(true);
setStage('select');
if (!partial) {
if (all.length > 50) {
setError('待上架 SKU 超过 50 个,请选择本次需要上架的 SKU,分批提交。');
} else if (!all.length) {
setError('没有可首次上架的 SKU。已导入的 SKU 请前往在线商品查看。');
} else await check(data, all);
}
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
const submit = async () => {
setBusy(true);
setError('');
requestKey.current ??= crypto.randomUUID();
try {
const result = await productEditorApi.createPublishJob(
saved.profile.id,
platformId,
ids,
{ product: saved.product.revision, profile: saved.profile.revision },
requestKey.current
);
setJob(result.publish_job);
setStage('result');
await onStatusChange();
} catch (e) {
setError(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
const chosen = saved.variants.filter((v) => ids.includes(v.id));
const issues = preflight
? [
...preflight.validation.profile.issues.map((i) => ({ ...i, scope: '商品资料' })),
...preflight.validation.variants.flatMap((v) =>
v.issues.map((i) => ({
...i,
scope:
v.display_name ||
saved.variants.find((s) => s.id === v.variant_id)?.listing.offer_id ||
'SKU',
}))
),
]
: [];
const statusTone = job?.status === 'succeeded'
? 'success'
: job?.status === 'failed'
? 'error'
: job?.status === 'partially_succeeded' || job?.status === 'reconciling'
? 'warning'
: 'progress';
const statusIcon = statusTone === 'success'
? <CheckCircleOutlined />
: statusTone === 'progress'
? <ClockCircleOutlined />
: <ExclamationCircleOutlined />;
return (
<>
{/* 发布状态与结果弹窗共用任务数据,状态入口展示在页头。 */}
{job && statusContainer && createPortal(
<button
type="button"
className={`editor-publish-status editor-publish-status--${statusTone}`}
onClick={() => {
setStage('result');
setOpen(true);
setError('');
}}
aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`}
>
<span className="editor-publish-status-icon" aria-hidden="true">
{statusIcon}
</span>
<span className="editor-publish-status-content">
<span className="editor-publish-status-title">
发布任务 · {jobLabels[job.status]}
</span>
<span className="editor-publish-status-count">{job.item_count} 个 SKU</span>
</span>
<span className="editor-publish-status-link">
查看详情 <RightOutlined />
</span>
</button>,
statusContainer
)}
<Space size={8} wrap>
<Button
type="primary"
disabled={disabled || busy}
loading={busy && !open}
onClick={() => void start(false)}
>
上架至 OZON
</Button>
<Button
type="primary"
ghost
disabled={disabled || busy}
onClick={() => void start(true)}
>
部分上架
</Button>
</Space>
<Modal
title={
stage === 'result'
? 'Ozon 导入结果'
: stage === 'confirm'
? '确认上架至 OZON'
: '选择与校验上架 SKU'
}
open={open}
width={900}
onCancel={() => {
run.current++;
setOpen(false);
setBusy(false);
}}
footer={
stage === 'result' ? (
<Button onClick={() => setOpen(false)}>关闭</Button>
) : (
<Space>
<Button
onClick={() => {
run.current++;
setOpen(false);
setBusy(false);
}}
>
取消
</Button>
{stage === 'confirm' ? (
<Button type="primary" loading={busy} onClick={() => void submit()}>
提交 {ids.length} 个 SKU
</Button>
) : (
<Button
type="primary"
loading={busy}
disabled={!ids.length || ids.length > 50}
onClick={() => void check(saved, ids)}
>
校验所选 SKU
</Button>
)}
</Space>
)
}
>
{error && (
<Alert type="error" showIcon title={error} style={{ marginBottom: 16 }} />
)}
{stage === 'result' && job ? (
<>
<Alert
type={
job.status === 'failed'
? 'error'
: job.status === 'reconciling'
? 'warning'
: terminal(job)
? 'success'
: 'info'
}
showIcon
title={`${jobLabels[job.status]} · ${job.item_count} 个 SKU`}
description={
job.status === 'reconciling'
? '平台受理结果尚未确认,系统将继续对账,请勿重复导入。'
: '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。'
}
style={{ marginBottom: 16 }}
/>
<Table
rowKey="id"
pagination={false}
dataSource={job.items}
columns={[
{ title: 'SKU', render: (_, i) => i.display_name || '未命名 SKU' },
{ title: '货号', dataIndex: 'offer_id' },
{
title: '状态',
render: (_, i) => (
<Tag
color={
i.status === 'imported'
? 'green'
: i.status === 'failed'
? 'red'
: undefined
}
>
{itemLabels[i.status]}
</Tag>
),
},
{
title: '结果说明',
render: (_, i) =>
i.errors.map((e) => (
<div key={`${e.code}:${e.message}`}>{e.message}</div>
)),
},
]}
/>
</>
) : (
<>
<Typography.Paragraph>
目标店铺:{saved.profile.target_store.name}。本次最多 50 个
SKU,仅首次导入商品资料;库存需在 Ozon 后台设置。
</Typography.Paragraph>
{saved.variants.some(
(v) => !['not_listed', 'failed'].includes(v.listing.status)
) && (
<Alert
type="info"
showIcon
title="以下 SKU 本次不重复提交"
description={saved.variants
.filter(
(v) => !['not_listed', 'failed'].includes(v.listing.status)
)
.map((v) => (
<div key={v.id}>
{v.display_name || v.listing.offer_id || '未命名 SKU'}:
{skipReason(v.listing.status)}
</div>
))}
style={{ marginBottom: 16 }}
/>
)}
{stage === 'select' ? (
<Table
rowKey="id"
size="small"
pagination={{ pageSize: 20 }}
dataSource={saved.variants}
rowSelection={{
selectedRowKeys: ids,
onChange: (keys) => {
setIds(keys as string[]);
setPreflight(null);
},
getCheckboxProps: (v) => ({
disabled: !['not_listed', 'failed'].includes(
v.listing.status
),
}),
}}
columns={[
{ title: 'SKU', dataIndex: 'display_name' },
{
title: '货号',
render: (_, v) => v.listing.offer_id || '未填写',
},
{
title: '状态',
render: (_, v) =>
v.listing.status === 'imported'
? '已导入'
: v.listing.status === 'submitting'
? '提交中'
: v.listing.status === 'failed'
? '可修正后重试'
: '待首次上架',
},
]}
/>
) : (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={chosen}
columns={[
{ title: 'SKU', dataIndex: 'display_name' },
{ title: '货号', render: (_, v) => v.listing.offer_id },
{
title: '售价',
render: (_, v) =>
`${v.listing.selling_price} ${saved.profile.target_store.currency_code}`,
},
{ title: '图片', render: (_, v) => `${v.media.length} 张` },
]}
/>
)}
{preflight?.status === 'refreshing' && (
<Alert
type="info"
title="正在更新 Ozon 类目与属性目录,请稍候或重新校验"
style={{ marginTop: 16 }}
/>
)}
{!!issues.length && (
<Alert
type="warning"
showIcon
title="请完善以下资料后重新上架"
description={issues.map((i, index) => (
<div key={`${i.field}:${index}`}>
<strong>{i.scope}</strong>:{i.message}
</div>
))}
style={{ marginTop: 16 }}
/>
)}
{stage === 'confirm' && (
<Alert
type="success"
showIcon
title={`预检通过,将提交 ${ids.length} 个 SKU`}
style={{ marginTop: 16 }}
/>
)}
</>
)}
</Modal>
</>
);
}
@@ -62,6 +62,16 @@
color: #90889c;
font-size: 11px;
text-align: center;
padding: 0;
cursor: pointer;
font-family: inherit;
}
.sku-main-thumbnail:hover {
border-color: #8956f5;
}
.sku-main-thumbnail:focus-visible {
outline: 2px solid #8956f5;
outline-offset: 2px;
}
.sku-main-thumbnail img {
width: 100%;
@@ -212,6 +222,9 @@
color: #292433;
white-space: nowrap;
}
.sku-expand-button .anticon-down {
font-size: 12px;
}
.sku-card-expanded {
grid-column: 1 / -1;
grid-row: 3;
@@ -393,6 +406,9 @@
.sku-info-panel .sku-info-package-row .ant-form-item-label > label {
width: 100%;
}
.sku-info-panel .sku-info-variant-title .ant-form-item-label > label {
width: 100%;
}
.sku-package-label {
display: inline-flex;
flex: 1;
@@ -101,8 +101,10 @@ type Props = {
usesVariantTags: boolean;
usesVariantDescription: boolean;
usesVariantTitle: boolean;
onUseCommonTitle: () => void;
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
onAttributeChanged: () => void;
onImagesChanged: () => void;
};
@@ -130,8 +132,10 @@ export function ProductVariantSection({
usesVariantTags,
usesVariantDescription,
usesVariantTitle,
onUseCommonTitle,
onAiCopy,
onCompareCopy,
onAttributeChanged,
onImagesChanged,
}: Props) {
const [pricingAuto, setPricingAuto] = useState(
@@ -432,6 +436,7 @@ export function ProductVariantSection({
imageUrl={displayImages[0]?.url}
imageCount={selectedAssetIds.length}
sourcePreview={!!sourceMainCandidate}
onPick={pickImages}
/>
<div className="sku-card-main">
<div className="sku-card-content">
@@ -541,6 +546,7 @@ export function ProductVariantSection({
usesVariantDescription
}
usesVariantTitle={usesVariantTitle}
onUseCommonTitle={onUseCommonTitle}
skuInfoAspectDefinitions={
skuInfoAspectDefinitions
}
@@ -561,6 +567,7 @@ export function ProductVariantSection({
onLoadMoreDictionaryValues={
onLoadMoreDictionaryValues
}
onAttributeChanged={onAttributeChanged}
onAiCopy={onAiCopy}
onCompareCopy={onCompareCopy}
/>
@@ -4,18 +4,21 @@ type Props = {
imageUrl?: string;
imageCount: number;
sourcePreview: boolean;
onPick: () => void;
};
/** 收起态主图沿用 SKU 图片顺序,来源候选图不计入已选图片数量。 */
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview, onPick }: Props) {
const imageLabel = sourcePreview ? '来源图' : '主图';
return (
<div className="sku-main-preview">
<div
<button
className="sku-main-thumbnail"
role="img"
aria-label={`SKU ${sourcePreview ? '来源图片' : '主图'},已选 ${imageCount} 张图片`}
type="button"
onClick={onPick}
aria-label={`选择 SKU 图片,当前已选 ${imageCount} 张`}
title="选择 SKU 图片"
>
{imageUrl ? (
<>
@@ -28,7 +31,7 @@ export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
<span className="sku-main-count" aria-hidden="true">
<PictureOutlined /> {sourcePreview ? '已选 0 张' : `${imageCount} 张图`}
</span>
</div>
</button>
</div>
);
}
@@ -22,6 +22,7 @@ type Props = {
usesVariantTags: boolean;
usesVariantDescription: boolean;
usesVariantTitle: boolean;
onUseCommonTitle: () => void;
skuInfoAspectDefinitions: ProductAttributeSchema[];
colorNameDefinition: ProductAttributeSchema | undefined;
catalogSyncing: string | null;
@@ -30,6 +31,7 @@ type Props = {
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
onAttributeChanged: () => void;
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
};
@@ -66,6 +68,7 @@ export function SkuInfoPanel({
usesVariantTags,
usesVariantDescription,
usesVariantTitle,
onUseCommonTitle,
skuInfoAspectDefinitions,
colorNameDefinition,
catalogSyncing,
@@ -74,6 +77,7 @@ export function SkuInfoPanel({
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
onAttributeChanged,
onAiCopy,
onCompareCopy,
}: Props) {
@@ -86,7 +90,7 @@ 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">
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical" rules={[{max: 50, message: '货号最多 50 字符'}]}>
<Input placeholder="首次上架前填写" />
</Form.Item>
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
@@ -144,30 +148,39 @@ export function SkuInfoPanel({
</>
)}
<div className="sku-info-group-title">发布文案与类目属性</div>
<Form.Item
label="俄文发布标题"
layout="vertical"
className="sku-info-variant-title"
required={usesVariantTitle}
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name={[fieldName, 'title']} noStyle>
<Input placeholder="留空则继承 SPU 标题" />
</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>
{usesVariantTitle && (
<Form.Item
label={
<span className="sku-package-label">
<span>俄文标题</span>
<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
@@ -244,6 +257,7 @@ export function SkuInfoPanel({
itemLayout="vertical"
forceRequiredNames={['商品颜色']}
colorNameDefinition={colorNameDefinition}
onAttributeChanged={onAttributeChanged}
/>
)}
{skuInfoAspectDefinitions.length > 0 && !sourceId && (
+5
View File
@@ -8,6 +8,7 @@ import type {
ProductAiTask,
ProductEditorValidation,
ProductPublishPreflight,
ProductPublishJob,
Operation,
SaveProductEditorDraftRequest,
SaveProductEditorDraftResponse,
@@ -118,6 +119,10 @@ export const productEditorApi = {
{ variant_ids: variantIds },
{ headers: { 'X-Atlas-Platform-Id': platformId } }
),
createPublishJob: (profileId: string, platformId: string, variantIds: string[], revisions: {product: number; profile: number}, key: string) =>
api.post<{publish_job: ProductPublishJob}>(`/product-platform-profiles/${profileId}/publish-jobs`, {variant_ids: variantIds}, {headers: {'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': key, 'If-Match': `"${revisions.product}:${revisions.profile}"`}}),
publishJob: (id: string, platformId: string) => api.get<ProductPublishJob>(`/product-publish-jobs/${id}`, {headers: {'X-Atlas-Platform-Id': platformId}}),
publishJobs: (profileId: string, platformId: string) => api.get<ProductPublishJob[]>(`/product-platform-profiles/${profileId}/publish-jobs`, {headers: {'X-Atlas-Platform-Id': platformId}}),
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
api.post<{ run: ProductCatalogRefreshRun }>(
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,