feat(edit): 商品编辑页优化
This commit is contained in:
@@ -208,6 +208,8 @@ export class ProductEditorService {
|
||||
purchase_cost_cny: string | null;
|
||||
listing_id: string;
|
||||
offer_id: string | null;
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
status: string;
|
||||
selling_price: string | null;
|
||||
list_price: string | null;
|
||||
@@ -227,7 +229,8 @@ export class ProductEditorService {
|
||||
'missing' | 'ambiguous' | 'confirmed' | null;
|
||||
}>(
|
||||
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
|
||||
v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, l.status, l.selling_price::text,
|
||||
v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
|
||||
l.description, l.tags, l.status, l.selling_price::text,
|
||||
l.list_price::text, l.currency_code, v.specification_values,
|
||||
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
|
||||
v.net_weight_g::text, v.physical_spec_status,
|
||||
@@ -353,6 +356,13 @@ export class ProductEditorService {
|
||||
display_name: variant.display_name,
|
||||
sort_order: variant.sort_order,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
tags: Array.isArray(variant.tags)
|
||||
? variant.tags.filter(
|
||||
(value): value is string =>
|
||||
typeof value === 'string',
|
||||
)
|
||||
: [],
|
||||
description: variant.description,
|
||||
specification_values: filterSpecificationValues(
|
||||
variant.specification_values,
|
||||
),
|
||||
@@ -865,11 +875,14 @@ export class ProductEditorService {
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.product_variant_platform_listings
|
||||
SET offer_id = $1, store_id = $2, revision = revision + 1, updated_at = now()
|
||||
WHERE profile_id = $3 AND product_variant_id = $4 AND status <> 'imported'`,
|
||||
SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE profile_id = $5 AND product_variant_id = $6 AND status <> 'imported'`,
|
||||
[
|
||||
variant.offer_id,
|
||||
input.profile.target_store_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
profile.id,
|
||||
variant.id,
|
||||
],
|
||||
@@ -953,8 +966,10 @@ export class ProductEditorService {
|
||||
});
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.product_variant_platform_listings
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, currency_code)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id,
|
||||
currency_code, tags, description)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code,
|
||||
$7::jsonb, $8
|
||||
FROM atlas.stores s WHERE s.id = $5`,
|
||||
[
|
||||
productId,
|
||||
@@ -963,6 +978,8 @@ export class ProductEditorService {
|
||||
id,
|
||||
input.profile.target_store_id,
|
||||
variant.offer_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1481,9 +1498,13 @@ export class ProductEditorService {
|
||||
values: string[],
|
||||
unchanged: boolean,
|
||||
) {
|
||||
// 商品颜色允许多选;该用户输入规则优先于部分 Ozon 类目 schema 的单值标记。
|
||||
const isProductColor = definition.name.trim() === '商品颜色';
|
||||
const allowsMultipleValues = definition.is_collection || isProductColor;
|
||||
const maxValueCount = isProductColor ? 100 : definition.max_value_count;
|
||||
if (
|
||||
values.length > definition.max_value_count ||
|
||||
(!definition.is_collection && values.length !== 1)
|
||||
values.length > maxValueCount ||
|
||||
(!allowsMultipleValues && values.length !== 1)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
|
||||
@@ -52,6 +52,11 @@ type ScopedValidationIssue = {
|
||||
issue: ProductEditorValidation['profile']['issues'][number];
|
||||
};
|
||||
|
||||
/** 商品颜色是 SKU 维度的业务必填属性,即使部分 Ozon 类目将其标为选填。 */
|
||||
function isRequiredVariantAttribute(definition: ProductAttributeSchema) {
|
||||
return definition.is_required || definition.name.trim() === '商品颜色';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算商品草稿的首次上架就绪状态。
|
||||
* 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。
|
||||
@@ -298,7 +303,10 @@ export class ProductEditorValidationOperations {
|
||||
});
|
||||
|
||||
for (const definition of schema.filter(
|
||||
(item) => item.is_required && item.is_aspect && !item.editor_field,
|
||||
(item) =>
|
||||
isRequiredVariantAttribute(item) &&
|
||||
item.is_aspect &&
|
||||
!item.editor_field,
|
||||
)) {
|
||||
if (
|
||||
!hasConfirmedValue(
|
||||
|
||||
@@ -215,8 +215,8 @@
|
||||
max-width: 100% !important;
|
||||
min-width: 0;
|
||||
}
|
||||
/* SKU 规格面板的固定标签宽在纵向布局下放开 */
|
||||
.sku-spec-panel .ant-form-item-label {
|
||||
/* SKU 信息区的标签在纵向布局下保持自适应宽度。 */
|
||||
.sku-info-panel .ant-form-item-label {
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec,
|
||||
offer_id: variant.listing.offer_id,
|
||||
@@ -346,13 +348,12 @@ function millimetres(value: string): [string, string, string] | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 从采集属性中识别商品/包装各自的尺寸与重量字段,用于首次自动填充默认规格。 */
|
||||
/** 从采集属性中识别包装尺寸与重量,用于首次自动填充 SPU 默认包装规格。 */
|
||||
function detectedSpecs(attributes: CollectedAttribute[]) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const attribute of attributes) {
|
||||
const label = `${attribute.key} ${attribute.name}`.toLowerCase();
|
||||
const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label);
|
||||
const isPhysical = /product|item|physical|商品|产品|实物/.test(label);
|
||||
const isDimension = /dimension|size|length|width|height|尺寸|长宽高|规格/.test(label);
|
||||
const isWeight = /weight|重量|质量/.test(label);
|
||||
const dimensions = isDimension ? millimetres(attribute.value) : null;
|
||||
@@ -361,12 +362,7 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
|
||||
[result.package_length_mm, result.package_width_mm, result.package_height_mm] =
|
||||
dimensions;
|
||||
}
|
||||
if (isPhysical && dimensions) {
|
||||
[result.physical_length_mm, result.physical_width_mm, result.physical_height_mm] =
|
||||
dimensions;
|
||||
}
|
||||
if (isPackage && weight) result.shipping_weight_g = weight;
|
||||
if (isPhysical && weight) result.net_weight_g = weight;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -425,6 +421,8 @@ const ProductEditPage = () => {
|
||||
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
|
||||
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
|
||||
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
|
||||
const [usesVariantTags, setUsesVariantTags] = useState(false);
|
||||
const [usesVariantDescription, setUsesVariantDescription] = useState(false);
|
||||
const blocker = useBlocker(dirty);
|
||||
const sourceSpecsApplied = useRef(false);
|
||||
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
|
||||
@@ -469,6 +467,8 @@ const ProductEditPage = () => {
|
||||
next.variants.length > 0 &&
|
||||
next.variants.every((variant) => hasPackageDimensions(variant.package_spec))
|
||||
);
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
@@ -663,18 +663,14 @@ const ProductEditPage = () => {
|
||||
};
|
||||
}, [currentPlatform, snapshot]);
|
||||
const collected = sourceDraft ?? snapshot?.source_material ?? null;
|
||||
// 首次拿到采集数据时自动填充默认规格:只补空字段,绝不覆盖已有值;
|
||||
// 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值;
|
||||
// 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。
|
||||
// sourceSpecsApplied 保证整个页面生命周期只执行一次。
|
||||
useEffect(() => {
|
||||
if (sourceSpecsApplied.current || !collected) return;
|
||||
const specs = detectedSpecs(collected.attributes);
|
||||
const physical = form.getFieldValue(['common', 'default_physical_spec']);
|
||||
const packageSpec = form.getFieldValue(['common', 'default_package_spec']);
|
||||
const hasAppliedValue = [
|
||||
[physical?.length_mm, specs.physical_length_mm],
|
||||
[physical?.width_mm, specs.physical_width_mm],
|
||||
[physical?.height_mm, specs.physical_height_mm],
|
||||
[physical?.net_weight_g, specs.net_weight_g],
|
||||
[packageSpec?.length_mm, specs.package_length_mm],
|
||||
[packageSpec?.width_mm, specs.package_width_mm],
|
||||
[packageSpec?.height_mm, specs.package_height_mm],
|
||||
@@ -683,13 +679,6 @@ const ProductEditPage = () => {
|
||||
if (hasAppliedValue) {
|
||||
form.setFieldsValue({
|
||||
common: {
|
||||
default_physical_spec: {
|
||||
...physical,
|
||||
length_mm: physical?.length_mm ?? specs.physical_length_mm ?? null,
|
||||
width_mm: physical?.width_mm ?? specs.physical_width_mm ?? null,
|
||||
height_mm: physical?.height_mm ?? specs.physical_height_mm ?? null,
|
||||
net_weight_g: physical?.net_weight_g ?? specs.net_weight_g ?? null,
|
||||
},
|
||||
default_package_spec: {
|
||||
...packageSpec,
|
||||
length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null,
|
||||
@@ -775,6 +764,32 @@ const ProductEditPage = () => {
|
||||
setUsesVariantPackageDimensions(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 主题标签复制到各 SKU;后续可在每个 SKU 内独立维护。 */
|
||||
const useVariantTags = () => {
|
||||
const tags = (form.getFieldValue(['profile', 'tags']) ?? []) as string[];
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => ({
|
||||
...variant,
|
||||
tags: variant.tags.length ? variant.tags : tags,
|
||||
})),
|
||||
});
|
||||
setUsesVariantTags(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 产品描述复制到各 SKU;后续可在每个 SKU 内独立维护。 */
|
||||
const useVariantDescription = () => {
|
||||
const description = form.getFieldValue(['profile', 'description']) as string | null;
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => ({
|
||||
...variant,
|
||||
description: variant.description ?? description,
|
||||
})),
|
||||
});
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
if (!productId || !currentPlatform || !snapshot) return;
|
||||
@@ -1073,6 +1088,10 @@ const ProductEditPage = () => {
|
||||
onOpenDictionaryValues={openDictionaryValues}
|
||||
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
|
||||
onSyncAttributeSchema={syncAttributeSchema}
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
onUseVariantTags={useVariantTags}
|
||||
onUseVariantDescription={useVariantDescription}
|
||||
/>
|
||||
|
||||
<ProductVariantSection
|
||||
@@ -1093,6 +1112,8 @@ const ProductEditPage = () => {
|
||||
onDirty={() => setDirty(true)}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
/>
|
||||
|
||||
<ProductOtherSection
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.collected-description-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.collected-field > strong {
|
||||
display: block;
|
||||
color: #352f43;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
@@ -50,6 +51,9 @@ export function CollectedProductInfoPanel({
|
||||
onShowDifferences,
|
||||
onAcceptLatest,
|
||||
}: Props) {
|
||||
const sourceDescription = collected?.description ?? '';
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
|
||||
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
|
||||
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
|
||||
// 画廊与详情富文本里,转存记录会以不同角色各存一条)。
|
||||
const imageGroups = (
|
||||
@@ -131,11 +135,20 @@ export function CollectedProductInfoPanel({
|
||||
)}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={collected?.description ?? ''}
|
||||
value={descriptionDraft}
|
||||
rows={12}
|
||||
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
|
||||
onChange={(event) => onUpdate('description', event.target.value)}
|
||||
onChange={(event) => setDescriptionDraft(event.target.value)}
|
||||
/>
|
||||
<div className="collected-description-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={descriptionDraft === sourceDescription}
|
||||
onClick={() => onUpdate('description', descriptionDraft)}
|
||||
>
|
||||
保存描述
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collected-field">
|
||||
<span>采集基础数据</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, InputNumber, Select, Typography } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, Select, Tooltip, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
@@ -10,6 +11,97 @@ import type {
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
type NamePath = Array<string | number>;
|
||||
const productColorValueLimit = 100;
|
||||
|
||||
/**
|
||||
* Ozon 商品颜色字典(attribute_id=10096)的 value_id → SVG 色值与俄文颜色名。
|
||||
* 数据来自 Ozon 卖家后台颜色下拉 DOM;键必须是 Ozon 字典值 ID,不能依赖会本地化或改名的显示文案。
|
||||
*/
|
||||
const ozonProductColorByValueId: Record<string, { color: string | null; russianName: string }> = {
|
||||
'258411654': { color: '#806b2a', russianName: 'хаки' },
|
||||
'970671251': { color: '#171717', russianName: 'матовый чёрный' },
|
||||
'369939085': { color: null, russianName: 'мультиколор' },
|
||||
'61584': { color: '#c3e6fe', russianName: 'небесно-голубой' },
|
||||
'258411648': { color: '#fffdd0', russianName: 'кремовый' },
|
||||
'61606': { color: '#422c16', russianName: 'шоколадный' },
|
||||
'61603': { color: '#7e1e00', russianName: 'красно-коричневый' },
|
||||
'61575': { color: '#924900', russianName: 'коричневый' },
|
||||
'61605': { color: '#808000', russianName: 'оливковый' },
|
||||
'61585': { color: '#ff9900', russianName: 'оранжевый' },
|
||||
'61591': { color: '#cd853f', russianName: 'светло-коричневый' },
|
||||
'61594': { color: '#cccccc', russianName: 'светло-серый' },
|
||||
'61593': { color: '#e2d2c3', russianName: 'светло-бежевый' },
|
||||
'61596': { color: '#ffcbde', russianName: 'светло-розовый' },
|
||||
'971039569': { color: '#db7093', russianName: 'светло-фиолетовый' },
|
||||
'61589': { color: '#89eb89', russianName: 'светло-зелёный' },
|
||||
'971001201': { color: '#4040ff', russianName: 'светло-синий' },
|
||||
'970673967': { color: '#f2f288', russianName: 'светло-жёлтый' },
|
||||
'61588': { color: '#9c85cd', russianName: 'бледно-фиолетовый' },
|
||||
'258411659': { color: '#7fff00', russianName: 'бледно-зелёный' },
|
||||
'61598': { color: '#573120', russianName: 'тёмно-коричневый' },
|
||||
'61600': { color: '#474a51', russianName: 'тёмно-серый' },
|
||||
'61604': { color: '#9a7a65', russianName: 'тёмно-бежевый' },
|
||||
'61611': { color: '#e75480', russianName: 'тёмно-розовый' },
|
||||
'61602': { color: '#00541f', russianName: 'тёмно-зелёный' },
|
||||
'61592': { color: '#00007e', russianName: 'тёмно-синий' },
|
||||
'970832145': { color: '#4a1517', russianName: 'тёмно-бордовый' },
|
||||
'61576': { color: '#666666', russianName: 'серый' },
|
||||
'61601': { color: '#ef774b', russianName: 'коралловый' },
|
||||
'971039568': { color: '#9c9c9c', russianName: 'перламутровый' },
|
||||
'61571': { color: '#ffffff', russianName: 'белый' },
|
||||
'61573': { color: '#c0987e', russianName: 'бежевый' },
|
||||
'61580': { color: '#fd85b2', russianName: 'розовый' },
|
||||
'61599': { color: '#f152db', russianName: 'пурпурный' },
|
||||
'61586': { color: '#6600cc', russianName: 'фиолетовый' },
|
||||
'970628491': { color: '#c400ab', russianName: 'красно-фиолетовый' },
|
||||
'61579': { color: '#ff0000', russianName: 'красный' },
|
||||
'970671252': { color: '#dc143c', russianName: 'ягодно-красный' },
|
||||
'61583': { color: '#287a41', russianName: 'зелёный' },
|
||||
'258411664': { color: '#ffdb58', russianName: 'горчичный' },
|
||||
'61581': { color: '#0000ff', russianName: 'синий' },
|
||||
'258411643': { color: '#007fff', russianName: 'лазурный' },
|
||||
'61597': { color: '#fbf3e1', russianName: 'слоновая кость' },
|
||||
'61572': { color: '#fffdfa', russianName: 'прозрачный' },
|
||||
'61590': { color: '#9b2d30', russianName: 'бордовый' },
|
||||
'61577': { color: '#b8b8b8', russianName: 'металлический серый' },
|
||||
'61582': { color: '#ffd700', russianName: 'золотой' },
|
||||
'61609': { color: '#b87333', russianName: 'медный' },
|
||||
'970726613': { color: '#dbe4eb', russianName: 'хромовый' },
|
||||
'61610': { color: '#c0c0c0', russianName: 'серебристый' },
|
||||
'970693003': { color: '#fffdfa', russianName: 'зеркальный' },
|
||||
'61595': { color: '#30d5c8', russianName: 'бирюзовый' },
|
||||
'61587': { color: '#e6d690', russianName: 'бронзовый' },
|
||||
'61578': { color: '#ffff00', russianName: 'жёлтый' },
|
||||
'61607': { color: '#454545', russianName: 'чёрно-серый' },
|
||||
'61574': { color: '#000000', russianName: 'чёрный' },
|
||||
};
|
||||
|
||||
/** 商品颜色允许一个 SKU 同时选择多个颜色,作为类目单值配置的业务例外。 */
|
||||
function isProductColor(definition: ProductAttributeSchema) {
|
||||
return definition.name.trim() === '商品颜色';
|
||||
}
|
||||
|
||||
/** 颜色展示严格按 Ozon 字典值 ID 取色,未知 ID 使用中性色以避免根据名称臆测。 */
|
||||
function colorSwatch(valueId: string) {
|
||||
const definition = ozonProductColorByValueId[valueId];
|
||||
if (definition?.color === null) return { color: null, isMulticolor: true };
|
||||
return { color: definition?.color ?? '#D0D5DD', isMulticolor: false };
|
||||
}
|
||||
|
||||
/** 颜色字典的下拉选项与已选标签共用该展示,提交值始终保持字典 ID。 */
|
||||
function ProductColorOption({ valueId, value }: { valueId: string; value: string }) {
|
||||
const swatch = colorSwatch(valueId);
|
||||
return (
|
||||
<span className="product-color-option">
|
||||
<span
|
||||
className={`product-color-swatch${swatch.isMulticolor ? ' product-color-swatch--multicolor' : ''}`}
|
||||
style={swatch.color ? { backgroundColor: swatch.color } : undefined}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: FormInstance<EditorFormValues>;
|
||||
@@ -23,6 +115,9 @@ type Props = {
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
emptyText?: string;
|
||||
itemLayout?: 'horizontal' | 'vertical';
|
||||
forceRequiredNames?: string[];
|
||||
colorNameDefinition?: ProductAttributeSchema;
|
||||
};
|
||||
|
||||
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
|
||||
@@ -51,6 +146,12 @@ function DynamicAttributeField({
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
itemLayout,
|
||||
forceRequiredNames,
|
||||
form,
|
||||
scope,
|
||||
variantId,
|
||||
colorNameDefinition,
|
||||
}: {
|
||||
definition: ProductAttributeSchema;
|
||||
index: number;
|
||||
@@ -60,6 +161,12 @@ function DynamicAttributeField({
|
||||
onSyncDictionaryValues: Props['onSyncDictionaryValues'];
|
||||
onOpenDictionaryValues: Props['onOpenDictionaryValues'];
|
||||
onLoadMoreDictionaryValues: Props['onLoadMoreDictionaryValues'];
|
||||
itemLayout: Props['itemLayout'];
|
||||
forceRequiredNames: Props['forceRequiredNames'];
|
||||
form: Props['form'];
|
||||
scope: Props['scope'];
|
||||
variantId: Props['variantId'];
|
||||
colorNameDefinition: Props['colorNameDefinition'];
|
||||
}) {
|
||||
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
||||
const onSyncRef = useRef(onSyncDictionaryValues);
|
||||
@@ -76,22 +183,87 @@ function DynamicAttributeField({
|
||||
|
||||
const valueName = ['attributes', index, 'values'] as NamePath;
|
||||
const scalarValueName = [...valueName, 0] as NamePath;
|
||||
const selectedProductColorValue = Form.useWatch(
|
||||
['attributes', index, 'values'],
|
||||
form
|
||||
) as unknown;
|
||||
const lastColorSelection = useRef<string | null>(null);
|
||||
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
|
||||
const isRequired =
|
||||
definition.is_required || forceRequiredNames?.includes(definition.name.trim()) === true;
|
||||
const maxValueCount = isProductColor(definition)
|
||||
? productColorValueLimit
|
||||
: definition.max_value_count;
|
||||
const dictionaryOptions = definition.dictionary_id
|
||||
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
|
||||
value: value.value_id,
|
||||
label: value.value,
|
||||
label: isProductColor(definition) ? (
|
||||
<ProductColorOption valueId={value.value_id} value={value.value} />
|
||||
) : (
|
||||
value.value
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
const selectedColorValueIds = Array.isArray(selectedProductColorValue)
|
||||
? selectedProductColorValue.filter((value): value is string => typeof value === 'string')
|
||||
: typeof selectedProductColorValue === 'string'
|
||||
? [selectedProductColorValue]
|
||||
: [];
|
||||
const colorSelectionKey = selectedColorValueIds.join('\u0000');
|
||||
/** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */
|
||||
const syncColorName = (selectedValue: unknown) => {
|
||||
if (!isProductColor(definition) || !colorNameDefinition) return;
|
||||
const valueIds = Array.isArray(selectedValue)
|
||||
? selectedValue.filter((value): value is string => typeof value === 'string')
|
||||
: typeof selectedValue === 'string'
|
||||
? [selectedValue]
|
||||
: [];
|
||||
const russianNames = valueIds.flatMap((valueId) => {
|
||||
const russianName = ozonProductColorByValueId[valueId]?.russianName;
|
||||
return russianName ? [russianName] : [];
|
||||
});
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
const colorNameIndex = attributeIndex(attributes, colorNameDefinition, scope, variantId);
|
||||
if (colorNameIndex < 0) return;
|
||||
form.setFieldValue(
|
||||
['attributes', colorNameIndex, 'values'],
|
||||
russianNames.length ? [russianNames.join(',')] : []
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isProductColor(definition) || !colorNameDefinition) return;
|
||||
if (lastColorSelection.current === null) {
|
||||
lastColorSelection.current = colorSelectionKey;
|
||||
return;
|
||||
}
|
||||
if (lastColorSelection.current === colorSelectionKey) return;
|
||||
lastColorSelection.current = colorSelectionKey;
|
||||
|
||||
// 监听表单值作为兜底,覆盖远程字典选项更新或以编程方式修改商品颜色的场景。
|
||||
syncColorName(selectedColorValueIds);
|
||||
}, [
|
||||
colorNameDefinition,
|
||||
colorSelectionKey,
|
||||
definition,
|
||||
form,
|
||||
scope,
|
||||
selectedColorValueIds,
|
||||
syncColorName,
|
||||
variantId,
|
||||
]);
|
||||
|
||||
const input =
|
||||
definition.value_type === 'dictionary' ? (
|
||||
<Form.Item name={definition.is_collection ? valueName : scalarValueName} noStyle>
|
||||
<Form.Item name={allowsMultipleValues ? valueName : scalarValueName} noStyle>
|
||||
<Select
|
||||
mode={definition.is_collection ? 'multiple' : undefined}
|
||||
maxCount={definition.is_collection ? definition.max_value_count : undefined}
|
||||
className={isProductColor(definition) ? 'product-color-select' : undefined}
|
||||
mode={allowsMultipleValues ? 'multiple' : undefined}
|
||||
maxCount={allowsMultipleValues ? maxValueCount : undefined}
|
||||
options={dictionaryOptions}
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onChange={(value) => syncColorName(value)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open || !definition.dictionary_id) return;
|
||||
setDictionaryQuery('');
|
||||
@@ -150,13 +322,13 @@ function DynamicAttributeField({
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.is_collection ? (
|
||||
) : allowsMultipleValues ? (
|
||||
<Form.Item name={valueName} noStyle>
|
||||
<Select
|
||||
mode="tags"
|
||||
maxCount={definition.max_value_count}
|
||||
maxCount={maxValueCount}
|
||||
tokenSeparators={[',']}
|
||||
placeholder={`输入后回车添加,最多 ${definition.max_value_count} 项`}
|
||||
placeholder={`输入后回车添加,最多 ${maxValueCount} 项`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
|
||||
@@ -175,12 +347,21 @@ function DynamicAttributeField({
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={definition.name}
|
||||
required={definition.is_required}
|
||||
className="dynamic-attribute-item"
|
||||
extra={
|
||||
definition.is_collection ? `最多填写 ${definition.max_value_count} 项` : undefined
|
||||
label={
|
||||
allowsMultipleValues ? (
|
||||
<span className="dynamic-attribute-label">
|
||||
<span>{definition.name}</span>
|
||||
<Tooltip title={`最多填写 ${maxValueCount} 项`}>
|
||||
<InfoCircleTwoTone aria-label={`最多填写 ${maxValueCount} 项`} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : (
|
||||
definition.name
|
||||
)
|
||||
}
|
||||
required={isRequired}
|
||||
layout={itemLayout}
|
||||
className="dynamic-attribute-item"
|
||||
>
|
||||
<div className="dynamic-attribute-control">{input}</div>
|
||||
</Form.Item>
|
||||
@@ -200,6 +381,9 @@ export function DynamicAttributeFields({
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
emptyText,
|
||||
itemLayout,
|
||||
forceRequiredNames,
|
||||
colorNameDefinition,
|
||||
}: Props) {
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
const fields = definitions.flatMap((definition) => {
|
||||
@@ -223,6 +407,12 @@ export function DynamicAttributeFields({
|
||||
onSyncDictionaryValues={onSyncDictionaryValues}
|
||||
onOpenDictionaryValues={onOpenDictionaryValues}
|
||||
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
|
||||
itemLayout={itemLayout}
|
||||
forceRequiredNames={forceRequiredNames}
|
||||
form={form}
|
||||
scope={scope}
|
||||
variantId={variantId}
|
||||
colorNameDefinition={colorNameDefinition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -54,9 +54,76 @@
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 多值字段把上限说明收进标题提示,避免额外说明行打乱 SKU 三列表单的节奏。 */
|
||||
.dynamic-attribute-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dynamic-attribute-label .anticon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dynamic-attribute-control,
|
||||
.dynamic-attribute-control .ant-select,
|
||||
.dynamic-attribute-control .ant-input,
|
||||
.dynamic-attribute-control .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* SPU 字段启用 SKU 覆盖前显示圆形加号,控件与按钮保持紧邻。 */
|
||||
.profile-override-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.profile-override-control > .ant-form-item {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile-override-control--description {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 商品颜色字典只改变显示,表单仍保存 Ozon 字典值 ID。 */
|
||||
.product-color-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.product-color-swatch {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgb(71 84 103 / 45%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* “多色”没有单一颜色,使用空心圆表达而不伪造填充色。 */
|
||||
.product-color-swatch--multicolor {
|
||||
border-color: #667085;
|
||||
background: transparent;
|
||||
}
|
||||
/* 多选标签保持单行控件的 32px 基准高度;多项换行时仍可按内容自然增高。 */
|
||||
.product-color-select.ant-select-multiple .ant-select-selector {
|
||||
box-sizing: border-box;
|
||||
min-height: 32px;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.product-color-select .ant-select-selection-overflow {
|
||||
align-items: center;
|
||||
}
|
||||
.product-color-select .ant-select-selection-item {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.product-color-select .product-color-option {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@@ -1,7 +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 { EditOutlined } from '@ant-design/icons';
|
||||
import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
@@ -37,6 +37,10 @@ type Props = {
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onSyncAttributeSchema: () => void;
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
onUseVariantTags: () => void;
|
||||
onUseVariantDescription: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -62,6 +66,10 @@ export function ProductAttributeSection({
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
onSyncAttributeSchema,
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
onUseVariantTags,
|
||||
onUseVariantDescription,
|
||||
}: Props) {
|
||||
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||
const [richContent, setRichContent] = useState('');
|
||||
@@ -87,17 +95,41 @@ export function ProductAttributeSection({
|
||||
title="产品属性"
|
||||
description="用于当前平台发布与 AI 文案生成。"
|
||||
/>
|
||||
<Form.Item name={['profile', 'tags']} label="主题标签">
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['profile', 'description']} label="产品描述" rules={[{ max: 100000 }]}>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
{!usesVariantTags && (
|
||||
<Form.Item label="主题标签">
|
||||
<div className="profile-override-control">
|
||||
<Form.Item name={['profile', 'tags']} noStyle>
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="按 SKU 分别填写主题标签"
|
||||
onClick={onUseVariantTags}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!usesVariantDescription && (
|
||||
<Form.Item label="产品描述" rules={[{ max: 100000 }]}>
|
||||
<div className="profile-override-control profile-override-control--description">
|
||||
<Form.Item name={['profile', 'description']} noStyle>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="按 SKU 分别填写产品描述"
|
||||
onClick={onUseVariantDescription}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name={['common', 'barcode']} label="条形码">
|
||||
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px)
|
||||
auto auto;
|
||||
22px 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
@@ -26,8 +26,10 @@
|
||||
text-align: center;
|
||||
}
|
||||
.package-dimension-unit {
|
||||
width: 22px;
|
||||
color: #667085;
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.package-dimensions-controls .ant-input-number {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { PlusCircleOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductMainInfoSection.css';
|
||||
@@ -42,7 +42,7 @@ function PackageFields({
|
||||
<Tooltip title="按 SKU 分别填写包装重量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="按 SKU 分别填写包装重量"
|
||||
onClick={onUseVariantPackageWeight}
|
||||
/>
|
||||
@@ -68,7 +68,7 @@ function PackageFields({
|
||||
<Tooltip title="按 SKU 分别填写包装尺寸">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="按 SKU 分别填写包装尺寸"
|
||||
onClick={onUseVariantPackageDimensions}
|
||||
/>
|
||||
@@ -118,7 +118,7 @@ export function ProductMainInfoSection({
|
||||
<SectionTitle
|
||||
number="01"
|
||||
title="主要信息"
|
||||
description="上架所需的核心资料。包装信息用于定价、物流与发布。"
|
||||
description="上架所需的核心资料。默认包装规格用于定价、物流与发布,也可分别按 SKU 覆盖。"
|
||||
/>
|
||||
<div className="editor-form-grid">
|
||||
<Form.Item
|
||||
|
||||
@@ -177,11 +177,6 @@
|
||||
.sku-attribute-panel .ant-form-item-label > label {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sku-card-expanded .sku-attribute-panel--optional {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
min-height: 32px;
|
||||
padding-top: 5px;
|
||||
@@ -193,7 +188,7 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 展开区:先展示 SKU 规格,再展示该 SKU 的图片选择区。 */
|
||||
/* 展开区按图片、SKU 信息的顺序展示。 */
|
||||
.sku-card-expanded {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -203,7 +198,7 @@
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-media-panel,
|
||||
.sku-spec-panel {
|
||||
.sku-info-panel {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -213,7 +208,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
.sku-media-panel h5.ant-typography,
|
||||
.sku-spec-panel h5.ant-typography {
|
||||
.sku-info-panel h5.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -224,8 +219,8 @@
|
||||
}
|
||||
.sku-media-panel h5.ant-typography::before,
|
||||
.sku-media-panel h5.ant-typography::after,
|
||||
.sku-spec-panel h5.ant-typography::before,
|
||||
.sku-spec-panel h5.ant-typography::after {
|
||||
.sku-info-panel h5.ant-typography::before,
|
||||
.sku-info-panel h5.ant-typography::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px dashed #d9d4df;
|
||||
@@ -300,32 +295,71 @@
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.sku-spec-panel {
|
||||
order: -1;
|
||||
.sku-info-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
.sku-info-panel h5.ant-typography,
|
||||
.sku-info-panel > .ant-typography {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item-label {
|
||||
flex: 0 0 78px !important;
|
||||
.sku-info-panel .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* SKU 信息保持标签在上、控件在下,独立于页面其他横向表单布局。 */
|
||||
.sku-info-panel .ant-form-item-row {
|
||||
display: block;
|
||||
}
|
||||
.sku-info-panel .ant-form-item-label {
|
||||
display: block;
|
||||
min-height: 26px;
|
||||
padding: 0 0 4px;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item-label > label {
|
||||
.sku-info-panel .ant-form-item-label > label {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item-control {
|
||||
.sku-info-panel .ant-form-item-control {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 独立的重量输入(非"长×宽×高"行内组)限制宽度 */
|
||||
.sku-spec-panel
|
||||
> .ant-form-item:not(:has(.sku-inline-dimensions))
|
||||
:is(.ant-input-group-wrapper, .ant-input-number-group-wrapper) {
|
||||
max-width: 220px;
|
||||
.sku-info-package-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-info-package-row .sku-info-package-dimensions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.sku-info-variant-description {
|
||||
grid-column: span 2;
|
||||
}
|
||||
/* 动态 SKU 属性也属于同一信息网格,不能再形成独立的横向属性区。 */
|
||||
.sku-info-panel .dynamic-attribute-list {
|
||||
display: contents;
|
||||
}
|
||||
.sku-info-panel .dynamic-attribute-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label {
|
||||
display: block;
|
||||
padding: 0 0 4px;
|
||||
text-align: left;
|
||||
}
|
||||
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px;
|
||||
grid-template-columns:
|
||||
minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr)
|
||||
28px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -359,4 +393,16 @@
|
||||
.sku-inline-dimensions > span {
|
||||
display: none;
|
||||
}
|
||||
.sku-info-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-info-package-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-info-package-row .sku-info-package-dimensions {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.sku-info-variant-description {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -91,6 +92,20 @@ function InlineDimensions({ name }: { name: NamePath }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ozon 不同类目会使用“颜色名称”或“印刷名称”承载颜色的文字表达。 */
|
||||
function isColorNameExpression(definition: ProductAttributeSchema) {
|
||||
const label = definition.name.trim();
|
||||
return label === '颜色名称' || label === '印刷名称';
|
||||
}
|
||||
|
||||
/** 商品颜色和其文字表达是卖家识别 SKU 的首要字段,优先展示且保留其余 schema 顺序。 */
|
||||
function skuInfoAttributeOrder(definition: ProductAttributeSchema) {
|
||||
const label = definition.name.trim();
|
||||
if (label === '商品颜色') return 0;
|
||||
if (isColorNameExpression(definition)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: FormInstance<EditorFormValues>;
|
||||
snapshot: ProductEditorSnapshot;
|
||||
@@ -107,11 +122,13 @@ type Props = {
|
||||
onDirty: () => void;
|
||||
usesVariantPackageWeight: boolean;
|
||||
usesVariantPackageDimensions: boolean;
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 「03 变体设置」区块:SKU 卡片(名称/主图/货号/价格摘要)与展开后的
|
||||
* 采集图片选择、包装与商品规格、规格轴编辑。
|
||||
* 采集图片选择、SKU 包装覆盖与类目变体属性编辑。
|
||||
*
|
||||
* 采集证据(collectionRecord)与素材转存由页面处理;本组件维护卡片
|
||||
* 展开状态与图片选择交互。
|
||||
@@ -132,34 +149,45 @@ export function ProductVariantSection({
|
||||
onDirty,
|
||||
usesVariantPackageWeight,
|
||||
usesVariantPackageDimensions,
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
}: Props) {
|
||||
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||
const requiredAspectDefinitions = activeAttributeSchema.filter(
|
||||
(definition) => definition.is_aspect && definition.is_required && !definition.editor_field
|
||||
(definition) =>
|
||||
definition.is_aspect &&
|
||||
definition.is_required &&
|
||||
definition.name.trim() !== '商品颜色' &&
|
||||
!definition.editor_field
|
||||
);
|
||||
const optionalAspectDefinitions = activeAttributeSchema.filter(
|
||||
(definition) => definition.is_aspect && !definition.is_required && !definition.editor_field
|
||||
const skuInfoAspectDefinitions = activeAttributeSchema
|
||||
.filter(
|
||||
(definition) =>
|
||||
definition.is_aspect &&
|
||||
(!definition.is_required || definition.name.trim() === '商品颜色') &&
|
||||
!definition.editor_field
|
||||
)
|
||||
.sort((left, right) => skuInfoAttributeOrder(left) - skuInfoAttributeOrder(right));
|
||||
const colorNameDefinition = activeAttributeSchema.find(
|
||||
(definition) =>
|
||||
definition.is_aspect && !definition.editor_field && isColorNameExpression(definition)
|
||||
);
|
||||
return (
|
||||
<Card className="editor-section sku-section" bordered={false}>
|
||||
<SectionTitle
|
||||
number="03"
|
||||
title="变体设置"
|
||||
description="卡片用于快速核对与填写;展开后可维护图片、规格轴和该 SKU 的覆盖规格。"
|
||||
description="卡片用于快速核对与填写;展开后可维护图片和该 SKU 的包装覆盖。"
|
||||
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
|
||||
/>
|
||||
<Form.List name="variants">
|
||||
{(fields, { add, remove }) => {
|
||||
// 新建变体默认值:id 为 null 表示服务端尚不存在,
|
||||
// client_key 作为保存前的临时标识。
|
||||
// 包装覆盖字段保持为空,发布时继承 SPU 默认值,避免持久化重复数据。
|
||||
const addVariant = () => {
|
||||
const defaultPackageSpec = form.getFieldValue([
|
||||
'common',
|
||||
'default_package_spec',
|
||||
]) as EditorFormValues['common']['default_package_spec'];
|
||||
add({
|
||||
id: null,
|
||||
client_key: crypto.randomUUID(),
|
||||
@@ -168,6 +196,8 @@ export function ProductVariantSection({
|
||||
source_sku: null,
|
||||
specification_values: [],
|
||||
purchase_cost_cny: null,
|
||||
tags: [],
|
||||
description: null,
|
||||
physical_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
@@ -176,18 +206,10 @@ export function ProductVariantSection({
|
||||
status: null,
|
||||
},
|
||||
package_spec: {
|
||||
length_mm: usesVariantPackageDimensions
|
||||
? defaultPackageSpec.length_mm
|
||||
: null,
|
||||
width_mm: usesVariantPackageDimensions
|
||||
? defaultPackageSpec.width_mm
|
||||
: null,
|
||||
height_mm: usesVariantPackageDimensions
|
||||
? defaultPackageSpec.height_mm
|
||||
: null,
|
||||
shipping_weight_g: usesVariantPackageWeight
|
||||
? defaultPackageSpec.shipping_weight_g
|
||||
: null,
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
height_mm: null,
|
||||
shipping_weight_g: null,
|
||||
status: null,
|
||||
},
|
||||
offer_id: null,
|
||||
@@ -438,36 +460,6 @@ export function ProductVariantSection({
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="sku-card-expanded">
|
||||
{optionalAspectDefinitions.length > 0 &&
|
||||
sourceId && (
|
||||
<div className="sku-attribute-panel sku-attribute-panel--optional">
|
||||
<Typography.Title level={5}>
|
||||
Ozon 选填变体属性
|
||||
</Typography.Title>
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={
|
||||
optionalAspectDefinitions
|
||||
}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={
|
||||
onLoadDictionaryValues
|
||||
}
|
||||
onSyncDictionaryValues={
|
||||
onSyncDictionaryValues
|
||||
}
|
||||
onOpenDictionaryValues={
|
||||
onOpenDictionaryValues
|
||||
}
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sku-media-panel">
|
||||
<Typography.Title level={5}>
|
||||
SKU 图片
|
||||
@@ -573,103 +565,125 @@ export function ProductVariantSection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sku-spec-panel">
|
||||
<Typography.Title level={5}>
|
||||
SKU 规格
|
||||
</Typography.Title>
|
||||
{usesVariantPackageWeight && (
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'package_spec',
|
||||
'shipping_weight_g',
|
||||
]}
|
||||
label="包装重量"
|
||||
>
|
||||
<InputNumber<string>
|
||||
min="0"
|
||||
step="0.001"
|
||||
stringMode
|
||||
addonAfter="g"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantPackageDimensions && (
|
||||
<Form.Item label="包装尺寸">
|
||||
<InlineDimensions
|
||||
name={[field.name, 'package_spec']}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'physical_spec',
|
||||
'net_weight_g',
|
||||
]}
|
||||
label="商品重量"
|
||||
>
|
||||
<InputNumber<string>
|
||||
min="0"
|
||||
step="0.001"
|
||||
stringMode
|
||||
addonAfter="g"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="商品尺寸">
|
||||
<InlineDimensions
|
||||
name={[field.name, 'physical_spec']}
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* 规格轴来自采集结果:颜色轴展示为"商品颜色" */}
|
||||
<Form.List
|
||||
name={[field.name, 'specification_values']}
|
||||
>
|
||||
{(specFields) =>
|
||||
specFields.length ? (
|
||||
<>
|
||||
{specFields.map((specField) => {
|
||||
const axisKey =
|
||||
form.getFieldValue([
|
||||
'variants',
|
||||
{(usesVariantPackageWeight ||
|
||||
usesVariantPackageDimensions ||
|
||||
usesVariantTags ||
|
||||
usesVariantDescription ||
|
||||
(skuInfoAspectDefinitions.length > 0 &&
|
||||
sourceId)) && (
|
||||
<div className="sku-info-panel">
|
||||
<Typography.Title level={5}>
|
||||
SKU 信息
|
||||
</Typography.Title>
|
||||
{(usesVariantPackageWeight ||
|
||||
usesVariantPackageDimensions) && (
|
||||
<div className="sku-info-package-row">
|
||||
{usesVariantPackageWeight && (
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'package_spec',
|
||||
'shipping_weight_g',
|
||||
]}
|
||||
label="包装重量"
|
||||
required
|
||||
layout="vertical"
|
||||
className="sku-info-package-weight"
|
||||
>
|
||||
<InputNumber<string>
|
||||
min="0"
|
||||
step="0.001"
|
||||
stringMode
|
||||
addonAfter="g"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantPackageDimensions && (
|
||||
<Form.Item
|
||||
label="包装尺寸"
|
||||
required
|
||||
layout="vertical"
|
||||
className="sku-info-package-dimensions"
|
||||
>
|
||||
<InlineDimensions
|
||||
name={[
|
||||
field.name,
|
||||
'specification_values',
|
||||
specField.name,
|
||||
'axis_key',
|
||||
]) as
|
||||
string | undefined;
|
||||
const label =
|
||||
/color|颜色/i.test(
|
||||
axisKey ?? ''
|
||||
)
|
||||
? '商品颜色'
|
||||
: axisKey ||
|
||||
'商品规格';
|
||||
return (
|
||||
<Form.Item
|
||||
key={specField.key}
|
||||
name={[
|
||||
specField.name,
|
||||
'value_name',
|
||||
]}
|
||||
label={label}
|
||||
>
|
||||
<Input placeholder="未填写" />
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<Form.Item label="商品颜色">
|
||||
<Input
|
||||
placeholder="未填写"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.List>
|
||||
</div>
|
||||
'package_spec',
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{usesVariantTags && (
|
||||
<Form.Item
|
||||
name={[field.name, 'tags']}
|
||||
label="主题标签"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-tags"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantDescription && (
|
||||
<Form.Item
|
||||
name={[field.name, 'description']}
|
||||
label="产品描述"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-description"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="填写该 SKU 的产品描述"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 &&
|
||||
sourceId && (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={
|
||||
skuInfoAspectDefinitions
|
||||
}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={
|
||||
dictionaryValues
|
||||
}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={
|
||||
onLoadDictionaryValues
|
||||
}
|
||||
onSyncDictionaryValues={
|
||||
onSyncDictionaryValues
|
||||
}
|
||||
onOpenDictionaryValues={
|
||||
onOpenDictionaryValues
|
||||
}
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
itemLayout="vertical"
|
||||
forceRequiredNames={[
|
||||
'商品颜色',
|
||||
]}
|
||||
colorNameDefinition={
|
||||
colorNameDefinition
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(usesVariantPackageWeight ||
|
||||
usesVariantPackageDimensions) && (
|
||||
<Typography.Text type="secondary">
|
||||
留空时使用 SPU 默认包装规格
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { PackageSpec, VariantPackageSpec } from '@atlas/contracts';
|
||||
|
||||
/** Ozon 商品导入所需的平台属性已经在发布快照中转换为该结构。 */
|
||||
export type OzonProductImportAttribute = {
|
||||
id: number;
|
||||
complex_id: number;
|
||||
values: Array<{
|
||||
value?: string;
|
||||
dictionary_value_id?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** `/v3/product/import` 单个 SKU 的稳定请求结构。 */
|
||||
export type OzonProductImportItem = {
|
||||
offer_id: string;
|
||||
name: string;
|
||||
description_category_id: number;
|
||||
type_id: number;
|
||||
price: number;
|
||||
currency_code?: string;
|
||||
description?: string;
|
||||
barcode?: string;
|
||||
images?: string[];
|
||||
attributes: OzonProductImportAttribute[];
|
||||
depth: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dimension_unit: 'mm';
|
||||
weight: number;
|
||||
weight_unit: 'g';
|
||||
};
|
||||
|
||||
export type OzonProductImportPayload = { items: OzonProductImportItem[] };
|
||||
|
||||
export type OzonProductImportSource = {
|
||||
default_package_spec: PackageSpec;
|
||||
category: {
|
||||
description_category_id: string | number;
|
||||
type_id: string | number;
|
||||
};
|
||||
title: string;
|
||||
description: string | null;
|
||||
barcode: string | null;
|
||||
/** is_aspect=false 的商品级 Ozon 属性。 */
|
||||
common_attributes: OzonProductImportAttribute[];
|
||||
variants: Array<{
|
||||
variant_id: string;
|
||||
offer_id: string | null;
|
||||
price: string | null;
|
||||
currency_code: string | null;
|
||||
package_spec: VariantPackageSpec;
|
||||
images: string[];
|
||||
attributes: OzonProductImportAttribute[];
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 发布快照不可转换为 Ozon payload 时抛出的确定性错误。 */
|
||||
export class OzonProductImportPayloadError extends Error {
|
||||
public constructor(
|
||||
public readonly variantId: string,
|
||||
public readonly field: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Worker 创建发布请求时按 SKU 展开包装规格。草稿只保存 SPU 默认值与
|
||||
* SKU 覆盖值,禁止为发布便利而将 SPU 默认值回写、复制到全部 SKU。
|
||||
*/
|
||||
export function buildOzonProductImportPayload(
|
||||
source: OzonProductImportSource,
|
||||
): OzonProductImportPayload {
|
||||
const categoryId = positiveInteger(
|
||||
source.category.description_category_id,
|
||||
'category.description_category_id',
|
||||
);
|
||||
const typeId = positiveInteger(source.category.type_id, 'category.type_id');
|
||||
const name = requiredText(source.title, 'title');
|
||||
|
||||
return {
|
||||
items: source.variants.map((variant) => {
|
||||
const packageSpec = effectivePackageSpec(
|
||||
source.default_package_spec,
|
||||
variant.package_spec,
|
||||
);
|
||||
const currencyCode = optionalText(variant.currency_code);
|
||||
const description = optionalText(source.description);
|
||||
const barcode = optionalText(source.barcode);
|
||||
return {
|
||||
offer_id: requiredText(
|
||||
variant.offer_id,
|
||||
'offer_id',
|
||||
variant.variant_id,
|
||||
),
|
||||
name,
|
||||
description_category_id: categoryId,
|
||||
type_id: typeId,
|
||||
price: positiveDecimal(
|
||||
variant.price,
|
||||
'price',
|
||||
variant.variant_id,
|
||||
),
|
||||
...(currencyCode ? { currency_code: currencyCode } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(barcode ? { barcode } : {}),
|
||||
...(variant.images.length ? { images: variant.images } : {}),
|
||||
// 每个 Ozon items[] 同时携带商品级属性和本 SKU 的 aspect 属性。
|
||||
attributes: [
|
||||
...source.common_attributes,
|
||||
...variant.attributes,
|
||||
],
|
||||
depth: positiveDecimal(
|
||||
packageSpec.length_mm,
|
||||
'package_spec.length_mm',
|
||||
variant.variant_id,
|
||||
),
|
||||
width: positiveDecimal(
|
||||
packageSpec.width_mm,
|
||||
'package_spec.width_mm',
|
||||
variant.variant_id,
|
||||
),
|
||||
height: positiveDecimal(
|
||||
packageSpec.height_mm,
|
||||
'package_spec.height_mm',
|
||||
variant.variant_id,
|
||||
),
|
||||
dimension_unit: 'mm',
|
||||
weight: positiveDecimal(
|
||||
packageSpec.shipping_weight_g,
|
||||
'package_spec.shipping_weight_g',
|
||||
variant.variant_id,
|
||||
),
|
||||
weight_unit: 'g',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** SKU 字段优先;空覆盖字段保留 SPU 默认包装规格作为最终物流值。 */
|
||||
function effectivePackageSpec(
|
||||
fallback: PackageSpec,
|
||||
override: VariantPackageSpec,
|
||||
): PackageSpec {
|
||||
return {
|
||||
length_mm: override.length_mm ?? fallback.length_mm,
|
||||
width_mm: override.width_mm ?? fallback.width_mm,
|
||||
height_mm: override.height_mm ?? fallback.height_mm,
|
||||
shipping_weight_g:
|
||||
override.shipping_weight_g ?? fallback.shipping_weight_g,
|
||||
status: override.status ?? fallback.status,
|
||||
};
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const text = value?.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function requiredText(
|
||||
value: string | null,
|
||||
field: string,
|
||||
variantId = 'profile',
|
||||
) {
|
||||
const text = optionalText(value);
|
||||
if (text) return text;
|
||||
throw new OzonProductImportPayloadError(
|
||||
variantId,
|
||||
field,
|
||||
`${field} 不能为空`,
|
||||
);
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | number, field: string) {
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
if (Number.isInteger(numberValue) && numberValue > 0) return numberValue;
|
||||
throw new OzonProductImportPayloadError(
|
||||
'profile',
|
||||
field,
|
||||
`${field} 必须为正整数`,
|
||||
);
|
||||
}
|
||||
|
||||
function positiveDecimal(
|
||||
value: string | null,
|
||||
field: string,
|
||||
variantId: string,
|
||||
) {
|
||||
const numberValue = value == null ? Number.NaN : Number(value);
|
||||
if (Number.isFinite(numberValue) && numberValue > 0) return numberValue;
|
||||
throw new OzonProductImportPayloadError(
|
||||
variantId,
|
||||
field,
|
||||
`${field} 必须大于 0`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildOzonProductImportPayload,
|
||||
OzonProductImportPayloadError,
|
||||
} from '../src/ozon-product-import.ts';
|
||||
|
||||
const defaultPackageSpec = {
|
||||
length_mm: '240',
|
||||
width_mm: '120',
|
||||
height_mm: '60',
|
||||
shipping_weight_g: '850',
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
function source(overrides = {}) {
|
||||
return {
|
||||
default_package_spec: defaultPackageSpec,
|
||||
category: { description_category_id: '17028922', type_id: '91234' },
|
||||
title: '测试商品',
|
||||
description: '商品描述',
|
||||
barcode: '1234567890123',
|
||||
common_attributes: [
|
||||
{ id: 85, complex_id: 0, values: [{ value: '品牌' }] },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
variant_id: 'sku-a',
|
||||
offer_id: 'offer-a',
|
||||
price: '199.90',
|
||||
currency_code: 'RUB',
|
||||
package_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
height_mm: null,
|
||||
shipping_weight_g: null,
|
||||
status: null,
|
||||
},
|
||||
images: ['https://cdn.example.test/a.jpg'],
|
||||
attributes: [
|
||||
{ id: 8229, complex_id: 0, values: [{ value: '红色' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('按 SKU 将 SPU 默认包装规格展开为 Ozon items[]', () => {
|
||||
const payload = buildOzonProductImportPayload(source());
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
items: [
|
||||
{
|
||||
offer_id: 'offer-a',
|
||||
name: '测试商品',
|
||||
description_category_id: 17028922,
|
||||
type_id: 91234,
|
||||
price: 199.9,
|
||||
currency_code: 'RUB',
|
||||
description: '商品描述',
|
||||
barcode: '1234567890123',
|
||||
images: ['https://cdn.example.test/a.jpg'],
|
||||
attributes: [
|
||||
{ id: 85, complex_id: 0, values: [{ value: '品牌' }] },
|
||||
{ id: 8229, complex_id: 0, values: [{ value: '红色' }] },
|
||||
],
|
||||
depth: 240,
|
||||
width: 120,
|
||||
height: 60,
|
||||
dimension_unit: 'mm',
|
||||
weight: 850,
|
||||
weight_unit: 'g',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('SKU 包装覆盖值优先于 SPU 默认值', () => {
|
||||
const input = source();
|
||||
input.variants[0].package_spec = {
|
||||
length_mm: '300',
|
||||
width_mm: '160',
|
||||
height_mm: '70',
|
||||
shipping_weight_g: '1000',
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
const payload = buildOzonProductImportPayload(input);
|
||||
assert.deepEqual(
|
||||
[
|
||||
payload.items[0].depth,
|
||||
payload.items[0].width,
|
||||
payload.items[0].height,
|
||||
payload.items[0].weight,
|
||||
],
|
||||
[300, 160, 70, 1000],
|
||||
);
|
||||
});
|
||||
|
||||
test('重量与尺寸可分别覆盖,未覆盖字段继续继承 SPU', () => {
|
||||
const input = source();
|
||||
input.variants[0].package_spec.shipping_weight_g = '920';
|
||||
|
||||
const item = buildOzonProductImportPayload(input).items[0];
|
||||
assert.deepEqual(
|
||||
[item.depth, item.width, item.height, item.weight],
|
||||
[240, 120, 60, 920],
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少有效包装规格时拒绝生成对应 SKU 的导入项', () => {
|
||||
const input = source({
|
||||
default_package_spec: {
|
||||
...defaultPackageSpec,
|
||||
shipping_weight_g: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildOzonProductImportPayload(input),
|
||||
(error) =>
|
||||
error instanceof OzonProductImportPayloadError &&
|
||||
error.variantId === 'sku-a' &&
|
||||
error.field === 'package_spec.shipping_weight_g',
|
||||
);
|
||||
});
|
||||
@@ -70,7 +70,9 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
||||
} }
|
||||
```
|
||||
|
||||
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
|
||||
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)、SKU 覆盖的 `tags` / `description` 及已选媒体。SKU 的 `tags: []`、`description: null` 分别表示继承 SPU 主题标签、产品描述;动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
|
||||
|
||||
`default_package_spec` 是 SPU 的存储事实,`variants[].package_spec` 仅保存该 SKU 的覆盖值;`effective_package_spec` 按“SKU 覆盖值优先、SPU 默认值回退”解析。Ozon 发布适配器在创建 job 时才将每个目标 SKU 的有效值物化为独立 `items[]`:`length_mm → depth`、`width_mm → width`、`height_mm → height`、`shipping_weight_g → weight`,并固定带上 `dimension_unit: "mm"` 与 `weight_unit: "g"`。不得为了 payload 预先将 SPU 默认值复制到所有 SKU 草稿。商品净重/尺寸不是这组固定物流字段;它们的 SPU/SKU 展示归属由当前 Ozon schema 的 `is_aspect` 决定。
|
||||
|
||||
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
|
||||
|
||||
@@ -100,7 +102,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
||||
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
|
||||
"physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": null },
|
||||
"package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": null },
|
||||
"offer_id": null,
|
||||
"offer_id": null, "tags": ["tag"], "description": "该 SKU 的描述",
|
||||
"media_asset_ids": ["uuid", "uuid"]
|
||||
}],
|
||||
"attributes": [{ "scope": "common", "variant_id": null, "platform_attribute_id": "100", "complex_id": "0", "values": ["value"], "language_code": "ru", "source": "manual", "confirmation_status": "confirmed" }]
|
||||
@@ -116,7 +118,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
||||
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
|
||||
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
||||
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
||||
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。实物规格只影响尺寸图,不用于物流。
|
||||
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。
|
||||
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
|
||||
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
|
||||
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。
|
||||
@@ -277,7 +279,7 @@ Headers:`Idempotency-Key` 必填。Body:
|
||||
|
||||
服务端在创建任务前重新运行第 3 节校验。只允许当前 profile 中状态为 `not_listed` 或 `failed` 的未删除 SKU;`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不得重新提交或把编辑草稿当平台更新。
|
||||
|
||||
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID 与平台适配请求,再投递 Worker。返回 `202`:
|
||||
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID、该 SKU 的有效包装规格和平台适配请求,再投递 Worker。适配请求中的每个 Ozon `items[]` 必须包含该 SKU 独立解析出的 `depth`、`width`、`height`、`dimension_unit`、`weight`、`weight_unit`,不得省略或传 0。返回 `202`:
|
||||
|
||||
```json
|
||||
{ "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 1. 目标与原则
|
||||
|
||||
Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的类目 schema 决定;同一 Ozon 平台的所有门店共用该 schema。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。当前编辑器和首次发布预检均读取平台级类目、属性和字典缓存;真正创建发布 job 并调用 Ozon 的闭环仍待后续实现。
|
||||
Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的类目 schema 决定;同一 Ozon 平台的所有门店共用该 schema。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。唯一的编辑器业务例外是名为“商品颜色”的 SKU 字典属性:即使 schema 标记为单值或选填,也按多选控件、最多 100 个值和上架必填处理。当前编辑器和首次发布预检均读取平台级类目、属性和字典缓存;真正创建发布 job 并调用 Ozon 的闭环仍待后续实现。
|
||||
|
||||
页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
|
||||
|
||||
@@ -21,7 +21,9 @@ Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的
|
||||
- 品牌;
|
||||
- 默认包装重量与默认包装尺寸。
|
||||
|
||||
默认包装规格是父商品级默认值。每个 SKU 默认继承该值;当某个 SKU 的包装不同,可在 SKU 卡片中设置包装重量和包装尺寸覆盖值。SKU 未填写覆盖值不表示缺失,而是继续继承主要信息中的默认包装规格。
|
||||
默认包装规格是父商品级默认值。每个 SKU 默认继承该值;当某个 SKU 的包装不同,可在 SKU 卡片中设置包装重量和包装尺寸覆盖值。SKU 未填写覆盖值不表示缺失,而是继续继承主要信息中的默认包装规格。包装重量和包装尺寸属于 Ozon 导入接口的固定物流字段,而不是由类目 schema 决定归属的动态属性:编辑器可以在重量、尺寸后分别提供“按 SKU 填写”的入口,但这只开启对应字段的 SKU 覆盖编辑,不应删除 SPU 默认值或把继承值冗余持久化到所有 SKU。
|
||||
|
||||
创建 Ozon 发布请求时,服务端必须为每个待发布 SKU 计算有效包装规格:SKU 覆盖值优先,否则取 SPU 默认值。每个 `/v3/product/import` 的 `items[]` 都要写入 `depth`(由 `length_mm` 映射)、`width`、`height`、`dimension_unit=mm`、`weight`(由 `shipping_weight_g` 映射)和 `weight_unit=g`;四个数值均必须存在且大于 0。该“逐 SKU 展开”发生在发布快照/payload 适配层,不要求草稿存储复制同一组 SPU 值。
|
||||
|
||||
品牌和型号既是主要信息,也是可供采集、AI 与多平台复用的 Atlas 标准字段。如果 Ozon 当前类目同时返回对应平台属性,发布适配器应建立显式映射并确保只有一个最终事实来源;字典型品牌必须由用户确认 Ozon 字典值,不能把自由文本直接冒充字典 ID。
|
||||
|
||||
@@ -37,6 +39,8 @@ Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的
|
||||
- 整数、十进制、布尔、文本和字典分别使用适合的输入控件。
|
||||
- 复杂属性按 Ozon 的复杂属性结构分组展示,发布时进入 `complex_attributes`,不得扁平化为普通属性。
|
||||
|
||||
“商品净重”和“商品尺寸”不等同于 Ozon 的包装物流规格,也不能因为 SKU 存在而默认显示。它们只有在当前类目 schema 返回相应属性时才展示:`is_aspect=true` 放入 SKU,`is_aspect=false` 放入 SPU;是否为上架必填由 `is_required` 决定。Atlas 可继续保存实物规格供尺寸图等内部能力使用,但不能把它当作 Ozon 固定物流字段,也不得替代有效包装规格。
|
||||
|
||||
例如,儿童玩具类目把「最小儿童年龄」和「最大儿童年龄」标为必填时,两个字段应直接显示在产品属性区,并在各自 Label 前显示红色 `*`。如果它们是字典属性,必须选择 Ozon 返回的年龄值,而不是任意输入文本。
|
||||
|
||||
### 2.3 SKU 设置
|
||||
@@ -49,7 +53,7 @@ Ozon schema 中 `is_aspect=true` 的属性放入每个 SKU 卡片的「Ozon 变
|
||||
- Ozon 变体属性,例如颜色、尺码;
|
||||
- 采购价、售价和划线价;
|
||||
- SKU 图片;
|
||||
- SKU 实物规格;
|
||||
- 当前 schema 要求或允许的 SKU 级商品净重/尺寸属性;
|
||||
- 对父商品默认包装规格的可选覆盖。
|
||||
|
||||
SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父商品默认包装规格与 SKU 覆盖值应明确显示继承关系,例如「继承默认值:320 g」和「为此 SKU 单独设置」。
|
||||
@@ -157,7 +161,7 @@ GET /api/product-platform-profiles/{profile_id}/validation
|
||||
- 所有通用必填属性有合法值;
|
||||
- 每个待上架 SKU 的必填变体属性有合法值;
|
||||
- 字典值、单/多值数量、数值类型与复杂属性结构有效;
|
||||
- SKU 的 `offer_id`、条形码、价格、图片和有效包装规格符合发布要求;
|
||||
- SKU 的 `offer_id`、条形码、价格、图片和有效包装规格符合发布要求;有效包装规格按“SKU 覆盖值 → SPU 默认值”解析,且在创建发布快照前必须可展开为该 SKU 的完整 Ozon 物流字段;
|
||||
- 已上架 SKU 返回 `not_publishable_again`,不作为普通字段缺失处理。
|
||||
|
||||
`POST /publish-jobs` 必须调用同一套校验,不能信任浏览器校验结果。保存草稿和创建发布任务应继续保持各自的事务与幂等边界。
|
||||
|
||||
@@ -35,11 +35,11 @@
|
||||
|
||||
保存仍由服务端按平台 schema 与字典值复核。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从平台类目记录取得;类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。首次发布前已提供 `publish-preflights` 复用同一套目录新鲜度与本地校验逻辑;真正创建发布任务并调用 Ozon 的流程尚未实现。完整规则见[平台目录缓存刷新方案](../architecture/platform-catalog-cache-refresh-plan.md)。
|
||||
|
||||
类目、标题、型号、品牌以及默认包装重量和默认包装尺寸固定放在「主要信息」区。SKU 默认继承父商品包装规格,仅在包装不同的情况下填写覆盖值。平台动态必填字段统一使用表单标准表现,在 Label 前显示红色 `*`;不追加「Ozon 必填」标签,也不在区块标题显示完成数量。更完整的目标交互、属性值表达、SKU 关联和发布校验改造见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](./ozon-dynamic-attributes-and-sku-form-plan.md)。
|
||||
类目、标题、型号、品牌以及默认包装重量和默认包装尺寸固定放在「主要信息」区。SKU 默认继承父商品包装规格,仅在包装不同的情况下填写覆盖值;重量、尺寸可分别开启 SKU 覆盖编辑,但 SPU 默认值仍是未覆盖 SKU 的事实来源。发布适配器必须在生成每个 Ozon `items[]` 时解析有效包装规格,而不是要求草稿向每个 SKU 重复保存相同值。平台动态必填字段统一使用表单标准表现,在 Label 前显示红色 `*`;不追加「Ozon 必填」标签,也不在区块标题显示完成数量。更完整的目标交互、属性值表达、SKU 关联和发布校验改造见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](./ozon-dynamic-attributes-and-sku-form-plan.md)。
|
||||
|
||||
## 3. SKU 卡片和列表
|
||||
|
||||
卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、条形码、采购价、售价、划线价、定价按钮、两组规格与 Ozon 变体属性。列表仅是紧凑视图,字段和数据完全相同。货号由用户填写,不自动复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。SKU 包装规格为空时继承主要信息中的默认包装规格;只有该 SKU 包装不同才填写覆盖值。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。
|
||||
卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、条形码、采购价、售价、划线价、定价按钮、按 schema 展示的商品规格与 Ozon 变体属性。列表仅是紧凑视图,字段和数据完全相同。货号由用户填写,不自动复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。SKU 包装规格为空时继承主要信息中的默认包装规格;只有该 SKU 包装不同才填写覆盖值。商品净重和商品尺寸不是 Ozon 固定物流字段,只在当前 schema 的对应属性为 SKU 级(`is_aspect=true`)时展示;商品级属性继续在 SPU 展示。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。
|
||||
|
||||
SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。
|
||||
|
||||
|
||||
@@ -213,6 +213,10 @@ export type ProductEditorSnapshot = {
|
||||
display_name: string | null;
|
||||
sort_order: number;
|
||||
purchase_cost_cny: string | null;
|
||||
/** 当前平台下该 SKU 对 SPU 主题标签的覆盖;空数组表示继承 SPU。 */
|
||||
tags: string[];
|
||||
/** 当前平台下该 SKU 对 SPU 产品描述的覆盖;null 表示继承 SPU。 */
|
||||
description: string | null;
|
||||
specification_values: SpecificationValue[];
|
||||
physical_spec: VariantPhysicalSpec;
|
||||
package_spec: VariantPackageSpec;
|
||||
@@ -493,6 +497,10 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,4})?$/)
|
||||
.nullable(),
|
||||
tags: z
|
||||
.array(z.string().trim().min(1).max(100))
|
||||
.max(30),
|
||||
description: nullableText(100_000),
|
||||
physical_spec: variantPhysicalSpec,
|
||||
package_spec: variantPackageSpec,
|
||||
offer_id: nullableText(500),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- SKU 可覆盖 SPU 的主题标签与产品描述;同一 SKU 在不同平台 profile 下可保留不同文案。
|
||||
ALTER TABLE atlas.product_variant_platform_listings
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN tags JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(tags) = 'array');
|
||||
Reference in New Issue
Block a user