feat(edit): 商品编辑页优化

This commit is contained in:
R524809
2026-09-21 17:44:50 +08:00
parent 75fa2b60f3
commit 05747d6f22
20 changed files with 1006 additions and 244 deletions
@@ -208,6 +208,8 @@ export class ProductEditorService {
purchase_cost_cny: string | null; purchase_cost_cny: string | null;
listing_id: string; listing_id: string;
offer_id: string | null; offer_id: string | null;
description: string | null;
tags: unknown;
status: string; status: string;
selling_price: string | null; selling_price: string | null;
list_price: string | null; list_price: string | null;
@@ -227,7 +229,8 @@ export class ProductEditorService {
'missing' | 'ambiguous' | 'confirmed' | null; 'missing' | 'ambiguous' | 'confirmed' | null;
}>( }>(
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, `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, 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.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
v.net_weight_g::text, v.physical_spec_status, v.net_weight_g::text, v.physical_spec_status,
@@ -353,6 +356,13 @@ export class ProductEditorService {
display_name: variant.display_name, display_name: variant.display_name,
sort_order: variant.sort_order, sort_order: variant.sort_order,
purchase_cost_cny: variant.purchase_cost_cny, 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( specification_values: filterSpecificationValues(
variant.specification_values, variant.specification_values,
), ),
@@ -865,11 +875,14 @@ export class ProductEditorService {
); );
await transaction.query( await transaction.query(
`UPDATE atlas.product_variant_platform_listings `UPDATE atlas.product_variant_platform_listings
SET offer_id = $1, store_id = $2, revision = revision + 1, updated_at = now() SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4,
WHERE profile_id = $3 AND product_variant_id = $4 AND status <> 'imported'`, revision = revision + 1, updated_at = now()
WHERE profile_id = $5 AND product_variant_id = $6 AND status <> 'imported'`,
[ [
variant.offer_id, variant.offer_id,
input.profile.target_store_id, input.profile.target_store_id,
JSON.stringify(variant.tags),
variant.description,
profile.id, profile.id,
variant.id, variant.id,
], ],
@@ -953,8 +966,10 @@ export class ProductEditorService {
}); });
await transaction.query( await transaction.query(
`INSERT INTO atlas.product_variant_platform_listings `INSERT INTO atlas.product_variant_platform_listings
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, currency_code) (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id,
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code 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`, FROM atlas.stores s WHERE s.id = $5`,
[ [
productId, productId,
@@ -963,6 +978,8 @@ export class ProductEditorService {
id, id,
input.profile.target_store_id, input.profile.target_store_id,
variant.offer_id, variant.offer_id,
JSON.stringify(variant.tags),
variant.description,
], ],
); );
} }
@@ -1481,9 +1498,13 @@ export class ProductEditorService {
values: string[], values: string[],
unchanged: boolean, unchanged: boolean,
) { ) {
// 商品颜色允许多选;该用户输入规则优先于部分 Ozon 类目 schema 的单值标记。
const isProductColor = definition.name.trim() === '商品颜色';
const allowsMultipleValues = definition.is_collection || isProductColor;
const maxValueCount = isProductColor ? 100 : definition.max_value_count;
if ( if (
values.length > definition.max_value_count || values.length > maxValueCount ||
(!definition.is_collection && values.length !== 1) (!allowsMultipleValues && values.length !== 1)
) )
throw new ApiError( throw new ApiError(
422, 422,
@@ -52,6 +52,11 @@ type ScopedValidationIssue = {
issue: ProductEditorValidation['profile']['issues'][number]; 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( 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 ( if (
!hasConfirmedValue( !hasConfirmedValue(
@@ -215,8 +215,8 @@
max-width: 100% !important; max-width: 100% !important;
min-width: 0; min-width: 0;
} }
/* SKU 规格面板的固定标签宽在纵向布局下放开 */ /* SKU 信息区的标签在纵向布局下保持自适应宽度。 */
.sku-spec-panel .ant-form-item-label { .sku-info-panel .ant-form-item-label {
flex: 0 0 auto !important; flex: 0 0 auto !important;
} }
} }
+41 -20
View File
@@ -137,6 +137,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
source_sku: variant.source_sku, source_sku: variant.source_sku,
specification_values: variant.specification_values, specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny, purchase_cost_cny: variant.purchase_cost_cny,
tags: variant.tags,
description: variant.description,
physical_spec: variant.physical_spec, physical_spec: variant.physical_spec,
package_spec: variant.package_spec, package_spec: variant.package_spec,
offer_id: variant.listing.offer_id, offer_id: variant.listing.offer_id,
@@ -346,13 +348,12 @@ function millimetres(value: string): [string, string, string] | null {
: null; : null;
} }
/** 从采集属性中识别商品/包装各自的尺寸与重量字段,用于首次自动填充默认规格。 */ /** 从采集属性中识别包装尺寸与重量,用于首次自动填充 SPU 默认包装规格。 */
function detectedSpecs(attributes: CollectedAttribute[]) { function detectedSpecs(attributes: CollectedAttribute[]) {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
const label = `${attribute.key} ${attribute.name}`.toLowerCase(); const label = `${attribute.key} ${attribute.name}`.toLowerCase();
const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label); 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 isDimension = /dimension|size|length|width|height|尺寸|长宽高|规格/.test(label);
const isWeight = /weight|重量|质量/.test(label); const isWeight = /weight|重量|质量/.test(label);
const dimensions = isDimension ? millimetres(attribute.value) : null; 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] = [result.package_length_mm, result.package_width_mm, result.package_height_mm] =
dimensions; 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 (isPackage && weight) result.shipping_weight_g = weight;
if (isPhysical && weight) result.net_weight_g = weight;
} }
return result; return result;
} }
@@ -425,6 +421,8 @@ const ProductEditPage = () => {
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false); const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false); const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
const [usesVariantTags, setUsesVariantTags] = useState(false);
const [usesVariantDescription, setUsesVariantDescription] = useState(false);
const blocker = useBlocker(dirty); const blocker = useBlocker(dirty);
const sourceSpecsApplied = useRef(false); const sourceSpecsApplied = useRef(false);
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
@@ -469,6 +467,8 @@ const ProductEditPage = () => {
next.variants.length > 0 && next.variants.length > 0 &&
next.variants.every((variant) => hasPackageDimensions(variant.package_spec)) 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); setDirty(false);
} catch (error) { } catch (error) {
message.error(apiErrorMessage(error)); message.error(apiErrorMessage(error));
@@ -663,18 +663,14 @@ const ProductEditPage = () => {
}; };
}, [currentPlatform, snapshot]); }, [currentPlatform, snapshot]);
const collected = sourceDraft ?? snapshot?.source_material ?? null; const collected = sourceDraft ?? snapshot?.source_material ?? null;
// 首次拿到采集数据时自动填充默认规格:只补空字段,绝不覆盖已有值; // 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值;
// 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。
// sourceSpecsApplied 保证整个页面生命周期只执行一次。 // sourceSpecsApplied 保证整个页面生命周期只执行一次。
useEffect(() => { useEffect(() => {
if (sourceSpecsApplied.current || !collected) return; if (sourceSpecsApplied.current || !collected) return;
const specs = detectedSpecs(collected.attributes); const specs = detectedSpecs(collected.attributes);
const physical = form.getFieldValue(['common', 'default_physical_spec']);
const packageSpec = form.getFieldValue(['common', 'default_package_spec']); const packageSpec = form.getFieldValue(['common', 'default_package_spec']);
const hasAppliedValue = [ 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?.length_mm, specs.package_length_mm],
[packageSpec?.width_mm, specs.package_width_mm], [packageSpec?.width_mm, specs.package_width_mm],
[packageSpec?.height_mm, specs.package_height_mm], [packageSpec?.height_mm, specs.package_height_mm],
@@ -683,13 +679,6 @@ const ProductEditPage = () => {
if (hasAppliedValue) { if (hasAppliedValue) {
form.setFieldsValue({ form.setFieldsValue({
common: { 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: { default_package_spec: {
...packageSpec, ...packageSpec,
length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null, length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null,
@@ -775,6 +764,32 @@ const ProductEditPage = () => {
setUsesVariantPackageDimensions(true); setUsesVariantPackageDimensions(true);
setDirty(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) => { const save = async (values: EditorFormValues) => {
if (!productId || !currentPlatform || !snapshot) return; if (!productId || !currentPlatform || !snapshot) return;
@@ -1073,6 +1088,10 @@ const ProductEditPage = () => {
onOpenDictionaryValues={openDictionaryValues} onOpenDictionaryValues={openDictionaryValues}
onLoadMoreDictionaryValues={loadMoreDictionaryValues} onLoadMoreDictionaryValues={loadMoreDictionaryValues}
onSyncAttributeSchema={syncAttributeSchema} onSyncAttributeSchema={syncAttributeSchema}
usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription}
onUseVariantTags={useVariantTags}
onUseVariantDescription={useVariantDescription}
/> />
<ProductVariantSection <ProductVariantSection
@@ -1093,6 +1112,8 @@ const ProductEditPage = () => {
onDirty={() => setDirty(true)} onDirty={() => setDirty(true)}
usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions} usesVariantPackageDimensions={usesVariantPackageDimensions}
usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription}
/> />
<ProductOtherSection <ProductOtherSection
@@ -61,6 +61,11 @@
padding: 0; padding: 0;
font-size: 12px; font-size: 12px;
} }
.collected-description-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.collected-field > strong { .collected-field > strong {
display: block; display: block;
color: #352f43; color: #352f43;
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd'; import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd';
import type { import type {
CollectionItemInspection, CollectionItemInspection,
@@ -50,6 +51,9 @@ export function CollectedProductInfoPanel({
onShowDifferences, onShowDifferences,
onAcceptLatest, onAcceptLatest,
}: Props) { }: Props) {
const sourceDescription = collected?.description ?? '';
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在 // 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
// 画廊与详情富文本里,转存记录会以不同角色各存一条)。 // 画廊与详情富文本里,转存记录会以不同角色各存一条)。
const imageGroups = ( const imageGroups = (
@@ -131,11 +135,20 @@ export function CollectedProductInfoPanel({
)} )}
</div> </div>
<Input.TextArea <Input.TextArea
value={collected?.description ?? ''} value={descriptionDraft}
rows={12} rows={12}
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。" 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>
<div className="collected-field"> <div className="collected-field">
<span>采集基础数据</span> <span>采集基础数据</span>
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; 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 type { FormInstance } from 'antd';
import { InfoCircleTwoTone } from '@ant-design/icons';
import type { import type {
ProductAttributeDictionaryValue, ProductAttributeDictionaryValue,
ProductAttributeSchema, ProductAttributeSchema,
@@ -10,6 +11,97 @@ import type {
type EditorFormValues = SaveProductEditorDraftRequest; type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number]; type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
type NamePath = Array<string | 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 = { type Props = {
form: FormInstance<EditorFormValues>; form: FormInstance<EditorFormValues>;
@@ -23,6 +115,9 @@ type Props = {
onOpenDictionaryValues: (dictionaryId: string) => void; onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void; onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
emptyText?: string; emptyText?: string;
itemLayout?: 'horizontal' | 'vertical';
forceRequiredNames?: string[];
colorNameDefinition?: ProductAttributeSchema;
}; };
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */ /** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
@@ -51,6 +146,12 @@ function DynamicAttributeField({
onSyncDictionaryValues, onSyncDictionaryValues,
onOpenDictionaryValues, onOpenDictionaryValues,
onLoadMoreDictionaryValues, onLoadMoreDictionaryValues,
itemLayout,
forceRequiredNames,
form,
scope,
variantId,
colorNameDefinition,
}: { }: {
definition: ProductAttributeSchema; definition: ProductAttributeSchema;
index: number; index: number;
@@ -60,6 +161,12 @@ function DynamicAttributeField({
onSyncDictionaryValues: Props['onSyncDictionaryValues']; onSyncDictionaryValues: Props['onSyncDictionaryValues'];
onOpenDictionaryValues: Props['onOpenDictionaryValues']; onOpenDictionaryValues: Props['onOpenDictionaryValues'];
onLoadMoreDictionaryValues: Props['onLoadMoreDictionaryValues']; 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 [dictionaryQuery, setDictionaryQuery] = useState('');
const onSyncRef = useRef(onSyncDictionaryValues); const onSyncRef = useRef(onSyncDictionaryValues);
@@ -76,22 +183,87 @@ function DynamicAttributeField({
const valueName = ['attributes', index, 'values'] as NamePath; const valueName = ['attributes', index, 'values'] as NamePath;
const scalarValueName = [...valueName, 0] 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 const dictionaryOptions = definition.dictionary_id
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({ ? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
value: value.value_id, 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 = const input =
definition.value_type === 'dictionary' ? ( definition.value_type === 'dictionary' ? (
<Form.Item name={definition.is_collection ? valueName : scalarValueName} noStyle> <Form.Item name={allowsMultipleValues ? valueName : scalarValueName} noStyle>
<Select <Select
mode={definition.is_collection ? 'multiple' : undefined} className={isProductColor(definition) ? 'product-color-select' : undefined}
maxCount={definition.is_collection ? definition.max_value_count : undefined} mode={allowsMultipleValues ? 'multiple' : undefined}
maxCount={allowsMultipleValues ? maxValueCount : undefined}
options={dictionaryOptions} options={dictionaryOptions}
showSearch showSearch
allowClear
filterOption={false} filterOption={false}
onChange={(value) => syncColorName(value)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open || !definition.dictionary_id) return; if (!open || !definition.dictionary_id) return;
setDictionaryQuery(''); setDictionaryQuery('');
@@ -150,13 +322,13 @@ function DynamicAttributeField({
placeholder={`选择${definition.name}`} placeholder={`选择${definition.name}`}
/> />
</Form.Item> </Form.Item>
) : definition.is_collection ? ( ) : allowsMultipleValues ? (
<Form.Item name={valueName} noStyle> <Form.Item name={valueName} noStyle>
<Select <Select
mode="tags" mode="tags"
maxCount={definition.max_value_count} maxCount={maxValueCount}
tokenSeparators={[',']} tokenSeparators={[',']}
placeholder={`输入后回车添加,最多 ${definition.max_value_count} 项`} placeholder={`输入后回车添加,最多 ${maxValueCount} 项`}
/> />
</Form.Item> </Form.Item>
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? ( ) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
@@ -175,12 +347,21 @@ function DynamicAttributeField({
return ( return (
<Form.Item <Form.Item
label={definition.name} label={
required={definition.is_required} allowsMultipleValues ? (
className="dynamic-attribute-item" <span className="dynamic-attribute-label">
extra={ <span>{definition.name}</span>
definition.is_collection ? `最多填写 ${definition.max_value_count} 项` : undefined <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> <div className="dynamic-attribute-control">{input}</div>
</Form.Item> </Form.Item>
@@ -200,6 +381,9 @@ export function DynamicAttributeFields({
onOpenDictionaryValues, onOpenDictionaryValues,
onLoadMoreDictionaryValues, onLoadMoreDictionaryValues,
emptyText, emptyText,
itemLayout,
forceRequiredNames,
colorNameDefinition,
}: Props) { }: Props) {
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[]; const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
const fields = definitions.flatMap((definition) => { const fields = definitions.flatMap((definition) => {
@@ -223,6 +407,12 @@ export function DynamicAttributeFields({
onSyncDictionaryValues={onSyncDictionaryValues} onSyncDictionaryValues={onSyncDictionaryValues}
onOpenDictionaryValues={onOpenDictionaryValues} onOpenDictionaryValues={onOpenDictionaryValues}
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues} onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
itemLayout={itemLayout}
forceRequiredNames={forceRequiredNames}
form={form}
scope={scope}
variantId={variantId}
colorNameDefinition={colorNameDefinition}
/> />
))} ))}
</div> </div>
@@ -54,9 +54,76 @@
white-space: normal; white-space: normal;
word-break: break-word; 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,
.dynamic-attribute-control .ant-select, .dynamic-attribute-control .ant-select,
.dynamic-attribute-control .ant-input, .dynamic-attribute-control .ant-input,
.dynamic-attribute-control .ant-input-number { .dynamic-attribute-control .ant-input-number {
width: 100%; 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 { useMemo, useState } from 'react';
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd'; import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
import type { FormInstance } from 'antd'; import type { FormInstance } from 'antd';
import { EditOutlined } from '@ant-design/icons'; import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons';
import type { import type {
ProductAttributeDictionaryValue, ProductAttributeDictionaryValue,
ProductAttributeSchema, ProductAttributeSchema,
@@ -37,6 +37,10 @@ type Props = {
onOpenDictionaryValues: (dictionaryId: string) => void; onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void; onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncAttributeSchema: () => void; onSyncAttributeSchema: () => void;
usesVariantTags: boolean;
usesVariantDescription: boolean;
onUseVariantTags: () => void;
onUseVariantDescription: () => void;
}; };
/** /**
@@ -62,6 +66,10 @@ export function ProductAttributeSection({
onOpenDictionaryValues, onOpenDictionaryValues,
onLoadMoreDictionaryValues, onLoadMoreDictionaryValues,
onSyncAttributeSchema, onSyncAttributeSchema,
usesVariantTags,
usesVariantDescription,
onUseVariantTags,
onUseVariantDescription,
}: Props) { }: Props) {
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。 // 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
const [richContent, setRichContent] = useState(''); const [richContent, setRichContent] = useState('');
@@ -87,7 +95,10 @@ export function ProductAttributeSection({
title="产品属性" title="产品属性"
description="用于当前平台发布与 AI 文案生成。" description="用于当前平台发布与 AI 文案生成。"
/> />
<Form.Item name={['profile', 'tags']} label="主题标签"> {!usesVariantTags && (
<Form.Item label="主题标签">
<div className="profile-override-control">
<Form.Item name={['profile', 'tags']} noStyle>
<Select <Select
className="editor-standard-select" className="editor-standard-select"
mode="tags" mode="tags"
@@ -95,9 +106,30 @@ export function ProductAttributeSection({
placeholder="输入后回车添加标签" placeholder="输入后回车添加标签"
/> />
</Form.Item> </Form.Item>
<Form.Item name={['profile', 'description']} label="产品描述" rules={[{ max: 100000 }]}> <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="商品卖点、规格与使用场景" /> <Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
</Form.Item> </Form.Item>
<Button
type="text"
icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写产品描述"
onClick={onUseVariantDescription}
/>
</div>
</Form.Item>
)}
<Form.Item name={['common', 'barcode']} label="条形码"> <Form.Item name={['common', 'barcode']} label="条形码">
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" /> <Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
</Form.Item> </Form.Item>
@@ -14,7 +14,7 @@
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px) minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px)
auto auto; 22px 32px;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
max-width: 100%; max-width: 100%;
@@ -26,8 +26,10 @@
text-align: center; text-align: center;
} }
.package-dimension-unit { .package-dimension-unit {
width: 22px;
color: #667085; color: #667085;
line-height: 32px; line-height: 32px;
white-space: nowrap;
} }
.package-dimensions-controls .ant-input-number { .package-dimensions-controls .ant-input-number {
width: 100%; width: 100%;
@@ -1,6 +1,6 @@
import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd'; import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd';
import type { FormInstance } 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 type { ProductEditorSnapshot } from '@atlas/contracts';
import { SectionTitle } from './SectionTitle'; import { SectionTitle } from './SectionTitle';
import './ProductMainInfoSection.css'; import './ProductMainInfoSection.css';
@@ -42,7 +42,7 @@ function PackageFields({
<Tooltip title="按 SKU 分别填写包装重量"> <Tooltip title="按 SKU 分别填写包装重量">
<Button <Button
type="text" type="text"
icon={<PlusOutlined />} icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写包装重量" aria-label="按 SKU 分别填写包装重量"
onClick={onUseVariantPackageWeight} onClick={onUseVariantPackageWeight}
/> />
@@ -68,7 +68,7 @@ function PackageFields({
<Tooltip title="按 SKU 分别填写包装尺寸"> <Tooltip title="按 SKU 分别填写包装尺寸">
<Button <Button
type="text" type="text"
icon={<PlusOutlined />} icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写包装尺寸" aria-label="按 SKU 分别填写包装尺寸"
onClick={onUseVariantPackageDimensions} onClick={onUseVariantPackageDimensions}
/> />
@@ -118,7 +118,7 @@ export function ProductMainInfoSection({
<SectionTitle <SectionTitle
number="01" number="01"
title="主要信息" title="主要信息"
description="上架所需的核心资料。包装信息用于定价、物流与发布。" description="上架所需的核心资料。默认包装规格用于定价、物流与发布,也可分别按 SKU 覆盖。"
/> />
<div className="editor-form-grid"> <div className="editor-form-grid">
<Form.Item <Form.Item
@@ -177,11 +177,6 @@
.sku-attribute-panel .ant-form-item-label > label { .sku-attribute-panel .ant-form-item-label > label {
justify-content: flex-start; justify-content: flex-start;
} }
.sku-card-expanded .sku-attribute-panel--optional {
margin: 0;
padding: 0;
border: 0;
}
.sku-card-value strong { .sku-card-value strong {
min-height: 32px; min-height: 32px;
padding-top: 5px; padding-top: 5px;
@@ -193,7 +188,7 @@
margin-left: auto; margin-left: auto;
} }
/* 展开区:先展示 SKU 规格,再展示该 SKU 的图片选择区。 */ /* 展开区按图片、SKU 信息的顺序展示。 */
.sku-card-expanded { .sku-card-expanded {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -203,7 +198,7 @@
border-top: 1px solid #f0edf3; border-top: 1px solid #f0edf3;
} }
.sku-media-panel, .sku-media-panel,
.sku-spec-panel { .sku-info-panel {
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -213,7 +208,7 @@
width: 100%; width: 100%;
} }
.sku-media-panel h5.ant-typography, .sku-media-panel h5.ant-typography,
.sku-spec-panel h5.ant-typography { .sku-info-panel h5.ant-typography {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -224,8 +219,8 @@
} }
.sku-media-panel h5.ant-typography::before, .sku-media-panel h5.ant-typography::before,
.sku-media-panel h5.ant-typography::after, .sku-media-panel h5.ant-typography::after,
.sku-spec-panel h5.ant-typography::before, .sku-info-panel h5.ant-typography::before,
.sku-spec-panel h5.ant-typography::after { .sku-info-panel h5.ant-typography::after {
content: ''; content: '';
flex: 1; flex: 1;
border-top: 1px dashed #d9d4df; border-top: 1px dashed #d9d4df;
@@ -300,32 +295,71 @@
grid-column: span 2; grid-column: span 2;
grid-row: span 2; grid-row: span 2;
} }
.sku-spec-panel { .sku-info-panel {
order: -1; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
} }
.sku-spec-panel .ant-form-item { .sku-info-panel h5.ant-typography,
margin-bottom: 10px; .sku-info-panel > .ant-typography {
grid-column: 1 / -1;
} }
.sku-spec-panel .ant-form-item-label { .sku-info-panel .ant-form-item {
flex: 0 0 78px !important; 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; 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; justify-content: flex-start;
font-weight: 600; font-weight: 600;
} }
.sku-spec-panel .ant-form-item-control { .sku-info-panel .ant-form-item-control {
display: block;
min-width: 0; min-width: 0;
} }
/* 独立的重量输入(非"长×宽×高"行内组)限制宽度 */ .sku-info-package-row {
.sku-spec-panel display: grid;
> .ant-form-item:not(:has(.sku-inline-dimensions)) grid-column: 1 / -1;
:is(.ant-input-group-wrapper, .ant-input-number-group-wrapper) { grid-template-columns: repeat(3, minmax(0, 1fr));
max-width: 220px; 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 { .sku-inline-dimensions {
display: grid; 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; gap: 6px;
align-items: center; align-items: center;
} }
@@ -359,4 +393,16 @@
.sku-inline-dimensions > span { .sku-inline-dimensions > span {
display: none; 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, Input,
InputNumber, InputNumber,
Popconfirm, Popconfirm,
Select,
Space, Space,
Tag, Tag,
Typography, 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 = { type Props = {
form: FormInstance<EditorFormValues>; form: FormInstance<EditorFormValues>;
snapshot: ProductEditorSnapshot; snapshot: ProductEditorSnapshot;
@@ -107,11 +122,13 @@ type Props = {
onDirty: () => void; onDirty: () => void;
usesVariantPackageWeight: boolean; usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean; usesVariantPackageDimensions: boolean;
usesVariantTags: boolean;
usesVariantDescription: boolean;
}; };
/** /**
* 「03 变体设置」区块:SKU 卡片(名称/主图/货号/价格摘要)与展开后的 * 「03 变体设置」区块:SKU 卡片(名称/主图/货号/价格摘要)与展开后的
* 采集图片选择、包装与商品规格、规格轴编辑。 * 采集图片选择、SKU 包装覆盖与类目变体属性编辑。
* *
* 采集证据(collectionRecord)与素材转存由页面处理;本组件维护卡片 * 采集证据(collectionRecord)与素材转存由页面处理;本组件维护卡片
* 展开状态与图片选择交互。 * 展开状态与图片选择交互。
@@ -132,34 +149,45 @@ export function ProductVariantSection({
onDirty, onDirty,
usesVariantPackageWeight, usesVariantPackageWeight,
usesVariantPackageDimensions, usesVariantPackageDimensions,
usesVariantTags,
usesVariantDescription,
}: Props) { }: Props) {
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。 // 实时监听变体草稿:驱动主图回显与候选图片的选中态。
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[]; const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。 // 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set()); const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
const requiredAspectDefinitions = activeAttributeSchema.filter( 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( const skuInfoAspectDefinitions = activeAttributeSchema
(definition) => definition.is_aspect && !definition.is_required && !definition.editor_field .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 ( return (
<Card className="editor-section sku-section" bordered={false}> <Card className="editor-section sku-section" bordered={false}>
<SectionTitle <SectionTitle
number="03" number="03"
title="变体设置" title="变体设置"
description="卡片用于快速核对与填写;展开后可维护图片、规格轴和该 SKU 的覆盖规格。" description="卡片用于快速核对与填写;展开后可维护图片和该 SKU 的包装覆盖。"
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>} extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
/> />
<Form.List name="variants"> <Form.List name="variants">
{(fields, { add, remove }) => { {(fields, { add, remove }) => {
// 新建变体默认值:id 为 null 表示服务端尚不存在, // 新建变体默认值:id 为 null 表示服务端尚不存在,
// client_key 作为保存前的临时标识。 // 包装覆盖字段保持为空,发布时继承 SPU 默认值,避免持久化重复数据。
const addVariant = () => { const addVariant = () => {
const defaultPackageSpec = form.getFieldValue([
'common',
'default_package_spec',
]) as EditorFormValues['common']['default_package_spec'];
add({ add({
id: null, id: null,
client_key: crypto.randomUUID(), client_key: crypto.randomUUID(),
@@ -168,6 +196,8 @@ export function ProductVariantSection({
source_sku: null, source_sku: null,
specification_values: [], specification_values: [],
purchase_cost_cny: null, purchase_cost_cny: null,
tags: [],
description: null,
physical_spec: { physical_spec: {
length_mm: null, length_mm: null,
width_mm: null, width_mm: null,
@@ -176,18 +206,10 @@ export function ProductVariantSection({
status: null, status: null,
}, },
package_spec: { package_spec: {
length_mm: usesVariantPackageDimensions length_mm: null,
? defaultPackageSpec.length_mm width_mm: null,
: null, height_mm: null,
width_mm: usesVariantPackageDimensions shipping_weight_g: null,
? defaultPackageSpec.width_mm
: null,
height_mm: usesVariantPackageDimensions
? defaultPackageSpec.height_mm
: null,
shipping_weight_g: usesVariantPackageWeight
? defaultPackageSpec.shipping_weight_g
: null,
status: null, status: null,
}, },
offer_id: null, offer_id: null,
@@ -438,36 +460,6 @@ export function ProductVariantSection({
)} )}
{expanded && ( {expanded && (
<div className="sku-card-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"> <div className="sku-media-panel">
<Typography.Title level={5}> <Typography.Title level={5}>
SKU 图片 SKU 图片
@@ -573,10 +565,19 @@ export function ProductVariantSection({
</div> </div>
)} )}
</div> </div>
<div className="sku-spec-panel"> {(usesVariantPackageWeight ||
usesVariantPackageDimensions ||
usesVariantTags ||
usesVariantDescription ||
(skuInfoAspectDefinitions.length > 0 &&
sourceId)) && (
<div className="sku-info-panel">
<Typography.Title level={5}> <Typography.Title level={5}>
SKU 规格 SKU 信息
</Typography.Title> </Typography.Title>
{(usesVariantPackageWeight ||
usesVariantPackageDimensions) && (
<div className="sku-info-package-row">
{usesVariantPackageWeight && ( {usesVariantPackageWeight && (
<Form.Item <Form.Item
name={[ name={[
@@ -585,6 +586,9 @@ export function ProductVariantSection({
'shipping_weight_g', 'shipping_weight_g',
]} ]}
label="包装重量" label="包装重量"
required
layout="vertical"
className="sku-info-package-weight"
> >
<InputNumber<string> <InputNumber<string>
min="0" min="0"
@@ -595,81 +599,91 @@ export function ProductVariantSection({
</Form.Item> </Form.Item>
)} )}
{usesVariantPackageDimensions && ( {usesVariantPackageDimensions && (
<Form.Item label="包装尺寸"> <Form.Item
label="包装尺寸"
required
layout="vertical"
className="sku-info-package-dimensions"
>
<InlineDimensions <InlineDimensions
name={[field.name, 'package_spec']} name={[
field.name,
'package_spec',
]}
/> />
</Form.Item> </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',
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> </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> </div>
)} )}
</Card> </Card>
+197
View File
@@ -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',
);
});
+6 -4
View File
@@ -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` 始终作为采集证据保留,不因编辑而改变。 `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", "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 }, "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 }, "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"] "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" }] "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 一起移除,不生成无主未映射属性。 - 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。 - `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。 - 删除已导入 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 永远保留其首次导入时的店铺快照。 - `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。 - 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。 - `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`,不得重新提交或把编辑草稿当平台更新。 服务端在创建任务前重新运行第 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 ```json
{ "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } } { "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } }
@@ -4,7 +4,7 @@
## 1. 目标与原则 ## 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 重新校验。 页面继续以 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。 品牌和型号既是主要信息,也是可供采集、AI 与多平台复用的 Atlas 标准字段。如果 Ozon 当前类目同时返回对应平台属性,发布适配器应建立显式映射并确保只有一个最终事实来源;字典型品牌必须由用户确认 Ozon 字典值,不能把自由文本直接冒充字典 ID。
@@ -37,6 +39,8 @@ Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的
- 整数、十进制、布尔、文本和字典分别使用适合的输入控件。 - 整数、十进制、布尔、文本和字典分别使用适合的输入控件。
- 复杂属性按 Ozon 的复杂属性结构分组展示,发布时进入 `complex_attributes`,不得扁平化为普通属性。 - 复杂属性按 Ozon 的复杂属性结构分组展示,发布时进入 `complex_attributes`,不得扁平化为普通属性。
“商品净重”和“商品尺寸”不等同于 Ozon 的包装物流规格,也不能因为 SKU 存在而默认显示。它们只有在当前类目 schema 返回相应属性时才展示:`is_aspect=true` 放入 SKU,`is_aspect=false` 放入 SPU;是否为上架必填由 `is_required` 决定。Atlas 可继续保存实物规格供尺寸图等内部能力使用,但不能把它当作 Ozon 固定物流字段,也不得替代有效包装规格。
例如,儿童玩具类目把「最小儿童年龄」和「最大儿童年龄」标为必填时,两个字段应直接显示在产品属性区,并在各自 Label 前显示红色 `*`。如果它们是字典属性,必须选择 Ozon 返回的年龄值,而不是任意输入文本。 例如,儿童玩具类目把「最小儿童年龄」和「最大儿童年龄」标为必填时,两个字段应直接显示在产品属性区,并在各自 Label 前显示红色 `*`。如果它们是字典属性,必须选择 Ozon 返回的年龄值,而不是任意输入文本。
### 2.3 SKU 设置 ### 2.3 SKU 设置
@@ -49,7 +53,7 @@ Ozon schema 中 `is_aspect=true` 的属性放入每个 SKU 卡片的「Ozon 变
- Ozon 变体属性,例如颜色、尺码; - Ozon 变体属性,例如颜色、尺码;
- 采购价、售价和划线价; - 采购价、售价和划线价;
- SKU 图片; - SKU 图片;
- SKU 实物规格; - 当前 schema 要求或允许的 SKU 级商品净重/尺寸属性;
- 对父商品默认包装规格的可选覆盖。 - 对父商品默认包装规格的可选覆盖。
SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父商品默认包装规格与 SKU 覆盖值应明确显示继承关系,例如「继承默认值:320 g」和「为此 SKU 单独设置」。 SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父商品默认包装规格与 SKU 覆盖值应明确显示继承关系,例如「继承默认值:320 g」和「为此 SKU 单独设置」。
@@ -157,7 +161,7 @@ GET /api/product-platform-profiles/{profile_id}/validation
- 所有通用必填属性有合法值; - 所有通用必填属性有合法值;
- 每个待上架 SKU 的必填变体属性有合法值; - 每个待上架 SKU 的必填变体属性有合法值;
- 字典值、单/多值数量、数值类型与复杂属性结构有效; - 字典值、单/多值数量、数值类型与复杂属性结构有效;
- SKU 的 `offer_id`、条形码、价格、图片和有效包装规格符合发布要求; - SKU 的 `offer_id`、条形码、价格、图片和有效包装规格符合发布要求;有效包装规格按“SKU 覆盖值 → SPU 默认值”解析,且在创建发布快照前必须可展开为该 SKU 的完整 Ozon 物流字段;
- 已上架 SKU 返回 `not_publishable_again`,不作为普通字段缺失处理。 - 已上架 SKU 返回 `not_publishable_again`,不作为普通字段缺失处理。
`POST /publish-jobs` 必须调用同一套校验,不能信任浏览器校验结果。保存草稿和创建发布任务应继续保持各自的事务与幂等边界。 `POST /publish-jobs` 必须调用同一套校验,不能信任浏览器校验结果。保存草稿和创建发布任务应继续保持各自的事务与幂等边界。
+2 -2
View File
@@ -35,11 +35,11 @@
保存仍由服务端按平台 schema 与字典值复核。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从平台类目记录取得;类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。首次发布前已提供 `publish-preflights` 复用同一套目录新鲜度与本地校验逻辑;真正创建发布任务并调用 Ozon 的流程尚未实现。完整规则见[平台目录缓存刷新方案](../architecture/platform-catalog-cache-refresh-plan.md)。 保存仍由服务端按平台 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 卡片和列表 ## 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。 SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。
+8
View File
@@ -213,6 +213,10 @@ export type ProductEditorSnapshot = {
display_name: string | null; display_name: string | null;
sort_order: number; sort_order: number;
purchase_cost_cny: string | null; purchase_cost_cny: string | null;
/** 当前平台下该 SKU 对 SPU 主题标签的覆盖;空数组表示继承 SPU。 */
tags: string[];
/** 当前平台下该 SKU 对 SPU 产品描述的覆盖;null 表示继承 SPU。 */
description: string | null;
specification_values: SpecificationValue[]; specification_values: SpecificationValue[];
physical_spec: VariantPhysicalSpec; physical_spec: VariantPhysicalSpec;
package_spec: VariantPackageSpec; package_spec: VariantPackageSpec;
@@ -493,6 +497,10 @@ export const saveProductEditorDraftRequestSchema = z
.string() .string()
.regex(/^\d+(\.\d{1,4})?$/) .regex(/^\d+(\.\d{1,4})?$/)
.nullable(), .nullable(),
tags: z
.array(z.string().trim().min(1).max(100))
.max(30),
description: nullableText(100_000),
physical_spec: variantPhysicalSpec, physical_spec: variantPhysicalSpec,
package_spec: variantPackageSpec, package_spec: variantPackageSpec,
offer_id: nullableText(500), 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');