feat(edit): 商品编辑页优化
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 +95,41 @@ export function ProductAttributeSection({
|
|||||||
title="产品属性"
|
title="产品属性"
|
||||||
description="用于当前平台发布与 AI 文案生成。"
|
description="用于当前平台发布与 AI 文案生成。"
|
||||||
/>
|
/>
|
||||||
<Form.Item name={['profile', 'tags']} label="主题标签">
|
{!usesVariantTags && (
|
||||||
<Select
|
<Form.Item label="主题标签">
|
||||||
className="editor-standard-select"
|
<div className="profile-override-control">
|
||||||
mode="tags"
|
<Form.Item name={['profile', 'tags']} noStyle>
|
||||||
tokenSeparators={[',']}
|
<Select
|
||||||
placeholder="输入后回车添加标签"
|
className="editor-standard-select"
|
||||||
/>
|
mode="tags"
|
||||||
</Form.Item>
|
tokenSeparators={[',']}
|
||||||
<Form.Item name={['profile', 'description']} label="产品描述" rules={[{ max: 100000 }]}>
|
placeholder="输入后回车添加标签"
|
||||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
/>
|
||||||
</Form.Item>
|
</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="条形码">
|
<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,103 +565,125 @@ export function ProductVariantSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sku-spec-panel">
|
{(usesVariantPackageWeight ||
|
||||||
<Typography.Title level={5}>
|
usesVariantPackageDimensions ||
|
||||||
SKU 规格
|
usesVariantTags ||
|
||||||
</Typography.Title>
|
usesVariantDescription ||
|
||||||
{usesVariantPackageWeight && (
|
(skuInfoAspectDefinitions.length > 0 &&
|
||||||
<Form.Item
|
sourceId)) && (
|
||||||
name={[
|
<div className="sku-info-panel">
|
||||||
field.name,
|
<Typography.Title level={5}>
|
||||||
'package_spec',
|
SKU 信息
|
||||||
'shipping_weight_g',
|
</Typography.Title>
|
||||||
]}
|
{(usesVariantPackageWeight ||
|
||||||
label="包装重量"
|
usesVariantPackageDimensions) && (
|
||||||
>
|
<div className="sku-info-package-row">
|
||||||
<InputNumber<string>
|
{usesVariantPackageWeight && (
|
||||||
min="0"
|
<Form.Item
|
||||||
step="0.001"
|
name={[
|
||||||
stringMode
|
field.name,
|
||||||
addonAfter="g"
|
'package_spec',
|
||||||
/>
|
'shipping_weight_g',
|
||||||
</Form.Item>
|
]}
|
||||||
)}
|
label="包装重量"
|
||||||
{usesVariantPackageDimensions && (
|
required
|
||||||
<Form.Item label="包装尺寸">
|
layout="vertical"
|
||||||
<InlineDimensions
|
className="sku-info-package-weight"
|
||||||
name={[field.name, 'package_spec']}
|
>
|
||||||
/>
|
<InputNumber<string>
|
||||||
</Form.Item>
|
min="0"
|
||||||
)}
|
step="0.001"
|
||||||
<Form.Item
|
stringMode
|
||||||
name={[
|
addonAfter="g"
|
||||||
field.name,
|
/>
|
||||||
'physical_spec',
|
</Form.Item>
|
||||||
'net_weight_g',
|
)}
|
||||||
]}
|
{usesVariantPackageDimensions && (
|
||||||
label="商品重量"
|
<Form.Item
|
||||||
>
|
label="包装尺寸"
|
||||||
<InputNumber<string>
|
required
|
||||||
min="0"
|
layout="vertical"
|
||||||
step="0.001"
|
className="sku-info-package-dimensions"
|
||||||
stringMode
|
>
|
||||||
addonAfter="g"
|
<InlineDimensions
|
||||||
/>
|
name={[
|
||||||
</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,
|
field.name,
|
||||||
'specification_values',
|
'package_spec',
|
||||||
specField.name,
|
]}
|
||||||
'axis_key',
|
/>
|
||||||
]) as
|
</Form.Item>
|
||||||
string | undefined;
|
)}
|
||||||
const label =
|
</div>
|
||||||
/color|颜色/i.test(
|
)}
|
||||||
axisKey ?? ''
|
{usesVariantTags && (
|
||||||
)
|
<Form.Item
|
||||||
? '商品颜色'
|
name={[field.name, 'tags']}
|
||||||
: axisKey ||
|
label="主题标签"
|
||||||
'商品规格';
|
layout="vertical"
|
||||||
return (
|
className="sku-info-variant-tags"
|
||||||
<Form.Item
|
>
|
||||||
key={specField.key}
|
<Select
|
||||||
name={[
|
mode="tags"
|
||||||
specField.name,
|
tokenSeparators={[',']}
|
||||||
'value_name',
|
placeholder="输入后回车添加标签"
|
||||||
]}
|
/>
|
||||||
label={label}
|
</Form.Item>
|
||||||
>
|
)}
|
||||||
<Input placeholder="未填写" />
|
{usesVariantDescription && (
|
||||||
</Form.Item>
|
<Form.Item
|
||||||
);
|
name={[field.name, 'description']}
|
||||||
})}
|
label="产品描述"
|
||||||
</>
|
layout="vertical"
|
||||||
) : (
|
className="sku-info-variant-description"
|
||||||
<Form.Item label="商品颜色">
|
>
|
||||||
<Input
|
<Input.TextArea
|
||||||
placeholder="未填写"
|
rows={3}
|
||||||
disabled
|
placeholder="填写该 SKU 的产品描述"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)
|
)}
|
||||||
}
|
{skuInfoAspectDefinitions.length > 0 &&
|
||||||
</Form.List>
|
sourceId && (
|
||||||
</div>
|
<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>
|
||||||
|
|||||||
@@ -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` 始终作为采集证据保留,不因编辑而改变。
|
`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` 必须调用同一套校验,不能信任浏览器校验结果。保存草稿和创建发布任务应继续保持各自的事务与幂等边界。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user