From 05747d6f2233f6a35083dff000abf4a792ed5e1c Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 21 Sep 2026 17:44:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20=E5=95=86=E5=93=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product-editor/product-editor.service.ts | 35 +- .../product-editor.validation.ts | 10 +- .../src/pages/products/ProductEditPage.css | 4 +- .../src/pages/products/ProductEditPage.tsx | 61 ++-- .../components/CollectedProductInfoPanel.css | 5 + .../components/CollectedProductInfoPanel.tsx | 17 +- .../components/DynamicAttributeFields.tsx | 216 +++++++++++- .../components/ProductAttributeSection.css | 67 ++++ .../components/ProductAttributeSection.tsx | 56 +++- .../components/ProductMainInfoSection.css | 4 +- .../components/ProductMainInfoSection.tsx | 8 +- .../components/ProductVariantSection.css | 94 ++++-- .../components/ProductVariantSection.tsx | 310 +++++++++--------- apps/worker/src/ozon-product-import.ts | 197 +++++++++++ apps/worker/test/ozon-product-import.test.mjs | 127 +++++++ docs/api/product-editor-and-publishing.md | 10 +- ...on-dynamic-attributes-and-sku-form-plan.md | 12 +- docs/product/product-ai-editor.md | 4 +- packages/contracts/src/product-editor.ts | 8 + .../028_variant_listing_content_overrides.sql | 5 + 20 files changed, 1006 insertions(+), 244 deletions(-) create mode 100644 apps/worker/src/ozon-product-import.ts create mode 100644 apps/worker/test/ozon-product-import.test.mjs create mode 100644 packages/database/migrations/028_variant_listing_content_overrides.sql diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 3266390..2c9668a 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -208,6 +208,8 @@ export class ProductEditorService { purchase_cost_cny: string | null; listing_id: string; offer_id: string | null; + description: string | null; + tags: unknown; status: string; selling_price: string | null; list_price: string | null; @@ -227,7 +229,8 @@ export class ProductEditorService { 'missing' | 'ambiguous' | 'confirmed' | null; }>( `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, - v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, l.status, l.selling_price::text, + v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, + l.description, l.tags, l.status, l.selling_price::text, l.list_price::text, l.currency_code, v.specification_values, v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, v.net_weight_g::text, v.physical_spec_status, @@ -353,6 +356,13 @@ export class ProductEditorService { display_name: variant.display_name, sort_order: variant.sort_order, purchase_cost_cny: variant.purchase_cost_cny, + tags: Array.isArray(variant.tags) + ? variant.tags.filter( + (value): value is string => + typeof value === 'string', + ) + : [], + description: variant.description, specification_values: filterSpecificationValues( variant.specification_values, ), @@ -865,11 +875,14 @@ export class ProductEditorService { ); await transaction.query( `UPDATE atlas.product_variant_platform_listings - SET offer_id = $1, store_id = $2, revision = revision + 1, updated_at = now() - WHERE profile_id = $3 AND product_variant_id = $4 AND status <> 'imported'`, + SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4, + revision = revision + 1, updated_at = now() + WHERE profile_id = $5 AND product_variant_id = $6 AND status <> 'imported'`, [ variant.offer_id, input.profile.target_store_id, + JSON.stringify(variant.tags), + variant.description, profile.id, variant.id, ], @@ -953,8 +966,10 @@ export class ProductEditorService { }); await transaction.query( `INSERT INTO atlas.product_variant_platform_listings - (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, currency_code) - SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code + (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, + currency_code, tags, description) + SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code, + $7::jsonb, $8 FROM atlas.stores s WHERE s.id = $5`, [ productId, @@ -963,6 +978,8 @@ export class ProductEditorService { id, input.profile.target_store_id, variant.offer_id, + JSON.stringify(variant.tags), + variant.description, ], ); } @@ -1481,9 +1498,13 @@ export class ProductEditorService { values: string[], unchanged: boolean, ) { + // 商品颜色允许多选;该用户输入规则优先于部分 Ozon 类目 schema 的单值标记。 + const isProductColor = definition.name.trim() === '商品颜色'; + const allowsMultipleValues = definition.is_collection || isProductColor; + const maxValueCount = isProductColor ? 100 : definition.max_value_count; if ( - values.length > definition.max_value_count || - (!definition.is_collection && values.length !== 1) + values.length > maxValueCount || + (!allowsMultipleValues && values.length !== 1) ) throw new ApiError( 422, diff --git a/apps/api/src/product-editor/product-editor.validation.ts b/apps/api/src/product-editor/product-editor.validation.ts index 2f38239..c05e780 100644 --- a/apps/api/src/product-editor/product-editor.validation.ts +++ b/apps/api/src/product-editor/product-editor.validation.ts @@ -52,6 +52,11 @@ type ScopedValidationIssue = { issue: ProductEditorValidation['profile']['issues'][number]; }; +/** 商品颜色是 SKU 维度的业务必填属性,即使部分 Ozon 类目将其标为选填。 */ +function isRequiredVariantAttribute(definition: ProductAttributeSchema) { + return definition.is_required || definition.name.trim() === '商品颜色'; +} + /** * 计算商品草稿的首次上架就绪状态。 * 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。 @@ -298,7 +303,10 @@ export class ProductEditorValidationOperations { }); for (const definition of schema.filter( - (item) => item.is_required && item.is_aspect && !item.editor_field, + (item) => + isRequiredVariantAttribute(item) && + item.is_aspect && + !item.editor_field, )) { if ( !hasConfirmedValue( diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index 6ef96c8..37e316a 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -215,8 +215,8 @@ max-width: 100% !important; min-width: 0; } - /* SKU 规格面板的固定标签宽在纵向布局下放开 */ - .sku-spec-panel .ant-form-item-label { + /* SKU 信息区的标签在纵向布局下保持自适应宽度。 */ + .sku-info-panel .ant-form-item-label { flex: 0 0 auto !important; } } diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 44030ca..8fdb5db 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -137,6 +137,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { source_sku: variant.source_sku, specification_values: variant.specification_values, purchase_cost_cny: variant.purchase_cost_cny, + tags: variant.tags, + description: variant.description, physical_spec: variant.physical_spec, package_spec: variant.package_spec, offer_id: variant.listing.offer_id, @@ -346,13 +348,12 @@ function millimetres(value: string): [string, string, string] | null { : null; } -/** 从采集属性中识别商品/包装各自的尺寸与重量字段,用于首次自动填充默认规格。 */ +/** 从采集属性中识别包装尺寸与重量,用于首次自动填充 SPU 默认包装规格。 */ function detectedSpecs(attributes: CollectedAttribute[]) { const result: Record = {}; for (const attribute of attributes) { const label = `${attribute.key} ${attribute.name}`.toLowerCase(); const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label); - const isPhysical = /product|item|physical|商品|产品|实物/.test(label); const isDimension = /dimension|size|length|width|height|尺寸|长宽高|规格/.test(label); const isWeight = /weight|重量|质量/.test(label); const dimensions = isDimension ? millimetres(attribute.value) : null; @@ -361,12 +362,7 @@ function detectedSpecs(attributes: CollectedAttribute[]) { [result.package_length_mm, result.package_width_mm, result.package_height_mm] = dimensions; } - if (isPhysical && dimensions) { - [result.physical_length_mm, result.physical_width_mm, result.physical_height_mm] = - dimensions; - } if (isPackage && weight) result.shipping_weight_g = weight; - if (isPhysical && weight) result.net_weight_g = weight; } return result; } @@ -425,6 +421,8 @@ const ProductEditPage = () => { const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false); const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false); + const [usesVariantTags, setUsesVariantTags] = useState(false); + const [usesVariantDescription, setUsesVariantDescription] = useState(false); const blocker = useBlocker(dirty); const sourceSpecsApplied = useRef(false); const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); @@ -469,6 +467,8 @@ const ProductEditPage = () => { next.variants.length > 0 && next.variants.every((variant) => hasPackageDimensions(variant.package_spec)) ); + setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0)); + setUsesVariantDescription(next.variants.some((variant) => !!variant.description)); setDirty(false); } catch (error) { message.error(apiErrorMessage(error)); @@ -663,18 +663,14 @@ const ProductEditPage = () => { }; }, [currentPlatform, snapshot]); const collected = sourceDraft ?? snapshot?.source_material ?? null; - // 首次拿到采集数据时自动填充默认规格:只补空字段,绝不覆盖已有值; + // 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值; + // 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。 // sourceSpecsApplied 保证整个页面生命周期只执行一次。 useEffect(() => { if (sourceSpecsApplied.current || !collected) return; const specs = detectedSpecs(collected.attributes); - const physical = form.getFieldValue(['common', 'default_physical_spec']); const packageSpec = form.getFieldValue(['common', 'default_package_spec']); const hasAppliedValue = [ - [physical?.length_mm, specs.physical_length_mm], - [physical?.width_mm, specs.physical_width_mm], - [physical?.height_mm, specs.physical_height_mm], - [physical?.net_weight_g, specs.net_weight_g], [packageSpec?.length_mm, specs.package_length_mm], [packageSpec?.width_mm, specs.package_width_mm], [packageSpec?.height_mm, specs.package_height_mm], @@ -683,13 +679,6 @@ const ProductEditPage = () => { if (hasAppliedValue) { form.setFieldsValue({ common: { - default_physical_spec: { - ...physical, - length_mm: physical?.length_mm ?? specs.physical_length_mm ?? null, - width_mm: physical?.width_mm ?? specs.physical_width_mm ?? null, - height_mm: physical?.height_mm ?? specs.physical_height_mm ?? null, - net_weight_g: physical?.net_weight_g ?? specs.net_weight_g ?? null, - }, default_package_spec: { ...packageSpec, length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null, @@ -775,6 +764,32 @@ const ProductEditPage = () => { setUsesVariantPackageDimensions(true); setDirty(true); }; + /** 将 SPU 主题标签复制到各 SKU;后续可在每个 SKU 内独立维护。 */ + const useVariantTags = () => { + const tags = (form.getFieldValue(['profile', 'tags']) ?? []) as string[]; + const variants = form.getFieldValue('variants') as EditorFormValues['variants']; + form.setFieldsValue({ + variants: variants.map((variant) => ({ + ...variant, + tags: variant.tags.length ? variant.tags : tags, + })), + }); + setUsesVariantTags(true); + setDirty(true); + }; + /** 将 SPU 产品描述复制到各 SKU;后续可在每个 SKU 内独立维护。 */ + const useVariantDescription = () => { + const description = form.getFieldValue(['profile', 'description']) as string | null; + const variants = form.getFieldValue('variants') as EditorFormValues['variants']; + form.setFieldsValue({ + variants: variants.map((variant) => ({ + ...variant, + description: variant.description ?? description, + })), + }); + setUsesVariantDescription(true); + setDirty(true); + }; /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { if (!productId || !currentPlatform || !snapshot) return; @@ -1073,6 +1088,10 @@ const ProductEditPage = () => { onOpenDictionaryValues={openDictionaryValues} onLoadMoreDictionaryValues={loadMoreDictionaryValues} onSyncAttributeSchema={syncAttributeSchema} + usesVariantTags={usesVariantTags} + usesVariantDescription={usesVariantDescription} + onUseVariantTags={useVariantTags} + onUseVariantDescription={useVariantDescription} /> { onDirty={() => setDirty(true)} usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageDimensions={usesVariantPackageDimensions} + usesVariantTags={usesVariantTags} + usesVariantDescription={usesVariantDescription} /> strong { display: block; color: #352f43; diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx index 98f3fbf..e878d2c 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd'; import type { CollectionItemInspection, @@ -50,6 +51,9 @@ export function CollectedProductInfoPanel({ onShowDifferences, onAcceptLatest, }: Props) { + const sourceDescription = collected?.description ?? ''; + const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription); + useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]); // 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在 // 画廊与详情富文本里,转存记录会以不同角色各存一条)。 const imageGroups = ( @@ -131,11 +135,20 @@ export function CollectedProductInfoPanel({ )} onUpdate('description', event.target.value)} + onChange={(event) => setDescriptionDraft(event.target.value)} /> +
+ +
采集基础数据 diff --git a/apps/web/src/pages/products/components/DynamicAttributeFields.tsx b/apps/web/src/pages/products/components/DynamicAttributeFields.tsx index febe130..03aaf46 100644 --- a/apps/web/src/pages/products/components/DynamicAttributeFields.tsx +++ b/apps/web/src/pages/products/components/DynamicAttributeFields.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { Button, Form, Input, InputNumber, Select, Typography } from 'antd'; +import { Button, Form, Input, InputNumber, Select, Tooltip, Typography } from 'antd'; import type { FormInstance } from 'antd'; +import { InfoCircleTwoTone } from '@ant-design/icons'; import type { ProductAttributeDictionaryValue, ProductAttributeSchema, @@ -10,6 +11,97 @@ import type { type EditorFormValues = SaveProductEditorDraftRequest; type AttributeDraft = NonNullable[number]; type NamePath = Array; +const productColorValueLimit = 100; + +/** + * Ozon 商品颜色字典(attribute_id=10096)的 value_id → SVG 色值与俄文颜色名。 + * 数据来自 Ozon 卖家后台颜色下拉 DOM;键必须是 Ozon 字典值 ID,不能依赖会本地化或改名的显示文案。 + */ +const ozonProductColorByValueId: Record = { + '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 ( + + + {value} + + ); +} type Props = { form: FormInstance; @@ -23,6 +115,9 @@ type Props = { onOpenDictionaryValues: (dictionaryId: string) => void; onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void; emptyText?: string; + itemLayout?: 'horizontal' | 'vertical'; + forceRequiredNames?: string[]; + colorNameDefinition?: ProductAttributeSchema; }; /** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */ @@ -51,6 +146,12 @@ function DynamicAttributeField({ onSyncDictionaryValues, onOpenDictionaryValues, onLoadMoreDictionaryValues, + itemLayout, + forceRequiredNames, + form, + scope, + variantId, + colorNameDefinition, }: { definition: ProductAttributeSchema; index: number; @@ -60,6 +161,12 @@ function DynamicAttributeField({ onSyncDictionaryValues: Props['onSyncDictionaryValues']; onOpenDictionaryValues: Props['onOpenDictionaryValues']; onLoadMoreDictionaryValues: Props['onLoadMoreDictionaryValues']; + itemLayout: Props['itemLayout']; + forceRequiredNames: Props['forceRequiredNames']; + form: Props['form']; + scope: Props['scope']; + variantId: Props['variantId']; + colorNameDefinition: Props['colorNameDefinition']; }) { const [dictionaryQuery, setDictionaryQuery] = useState(''); const onSyncRef = useRef(onSyncDictionaryValues); @@ -76,22 +183,87 @@ function DynamicAttributeField({ const valueName = ['attributes', index, 'values'] as NamePath; const scalarValueName = [...valueName, 0] as NamePath; + const selectedProductColorValue = Form.useWatch( + ['attributes', index, 'values'], + form + ) as unknown; + const lastColorSelection = useRef(null); + const allowsMultipleValues = definition.is_collection || isProductColor(definition); + const isRequired = + definition.is_required || forceRequiredNames?.includes(definition.name.trim()) === true; + const maxValueCount = isProductColor(definition) + ? productColorValueLimit + : definition.max_value_count; const dictionaryOptions = definition.dictionary_id ? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({ value: value.value_id, - label: value.value, + label: isProductColor(definition) ? ( + + ) : ( + value.value + ), })) : []; + const selectedColorValueIds = Array.isArray(selectedProductColorValue) + ? selectedProductColorValue.filter((value): value is string => typeof value === 'string') + : typeof selectedProductColorValue === 'string' + ? [selectedProductColorValue] + : []; + const colorSelectionKey = selectedColorValueIds.join('\u0000'); + /** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */ + const syncColorName = (selectedValue: unknown) => { + if (!isProductColor(definition) || !colorNameDefinition) return; + const valueIds = Array.isArray(selectedValue) + ? selectedValue.filter((value): value is string => typeof value === 'string') + : typeof selectedValue === 'string' + ? [selectedValue] + : []; + const russianNames = valueIds.flatMap((valueId) => { + const russianName = ozonProductColorByValueId[valueId]?.russianName; + return russianName ? [russianName] : []; + }); + const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[]; + const colorNameIndex = attributeIndex(attributes, colorNameDefinition, scope, variantId); + if (colorNameIndex < 0) return; + form.setFieldValue( + ['attributes', colorNameIndex, 'values'], + russianNames.length ? [russianNames.join(',')] : [] + ); + }; + useEffect(() => { + if (!isProductColor(definition) || !colorNameDefinition) return; + if (lastColorSelection.current === null) { + lastColorSelection.current = colorSelectionKey; + return; + } + if (lastColorSelection.current === colorSelectionKey) return; + lastColorSelection.current = colorSelectionKey; + + // 监听表单值作为兜底,覆盖远程字典选项更新或以编程方式修改商品颜色的场景。 + syncColorName(selectedColorValueIds); + }, [ + colorNameDefinition, + colorSelectionKey, + definition, + form, + scope, + selectedColorValueIds, + syncColorName, + variantId, + ]); const input = definition.value_type === 'dictionary' ? ( - + ) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? ( @@ -175,12 +347,21 @@ function DynamicAttributeField({ return ( + {definition.name} + + + + + ) : ( + definition.name + ) } + required={isRequired} + layout={itemLayout} + className="dynamic-attribute-item" >
{input}
@@ -200,6 +381,9 @@ export function DynamicAttributeFields({ onOpenDictionaryValues, onLoadMoreDictionaryValues, emptyText, + itemLayout, + forceRequiredNames, + colorNameDefinition, }: Props) { const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[]; const fields = definitions.flatMap((definition) => { @@ -223,6 +407,12 @@ export function DynamicAttributeFields({ onSyncDictionaryValues={onSyncDictionaryValues} onOpenDictionaryValues={onOpenDictionaryValues} onLoadMoreDictionaryValues={onLoadMoreDictionaryValues} + itemLayout={itemLayout} + forceRequiredNames={forceRequiredNames} + form={form} + scope={scope} + variantId={variantId} + colorNameDefinition={colorNameDefinition} /> ))}
diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.css b/apps/web/src/pages/products/components/ProductAttributeSection.css index 92b9297..45673fd 100644 --- a/apps/web/src/pages/products/components/ProductAttributeSection.css +++ b/apps/web/src/pages/products/components/ProductAttributeSection.css @@ -54,9 +54,76 @@ white-space: normal; word-break: break-word; } +/* 多值字段把上限说明收进标题提示,避免额外说明行打乱 SKU 三列表单的节奏。 */ +.dynamic-attribute-label { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.dynamic-attribute-label .anticon { + flex: 0 0 auto; + font-size: 14px; +} .dynamic-attribute-control, .dynamic-attribute-control .ant-select, .dynamic-attribute-control .ant-input, .dynamic-attribute-control .ant-input-number { width: 100%; } + +/* SPU 字段启用 SKU 覆盖前显示圆形加号,控件与按钮保持紧邻。 */ +.profile-override-control { + display: flex; + align-items: center; + gap: 4px; +} +.profile-override-control > .ant-form-item { + flex: 1 1 auto; + min-width: 0; +} +.profile-override-control--description { + align-items: flex-start; +} + +/* 商品颜色字典只改变显示,表单仍保存 Ozon 字典值 ID。 */ +.product-color-option { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.product-color-swatch { + flex: 0 0 auto; + width: 14px; + height: 14px; + box-sizing: border-box; + border: 1px solid rgb(71 84 103 / 45%); + border-radius: 50%; +} +/* “多色”没有单一颜色,使用空心圆表达而不伪造填充色。 */ +.product-color-swatch--multicolor { + border-color: #667085; + background: transparent; +} +/* 多选标签保持单行控件的 32px 基准高度;多项换行时仍可按内容自然增高。 */ +.product-color-select.ant-select-multiple .ant-select-selector { + box-sizing: border-box; + min-height: 32px; + padding-top: 1px; + padding-bottom: 1px; +} +.product-color-select .ant-select-selection-overflow { + align-items: center; +} +.product-color-select .ant-select-selection-item { + display: inline-flex; + height: 24px; + align-items: center; + margin-top: 2px; + margin-bottom: 2px; + line-height: 24px; +} +.product-color-select .product-color-option { + line-height: 20px; +} diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.tsx b/apps/web/src/pages/products/components/ProductAttributeSection.tsx index ebc2751..1ca12ee 100644 --- a/apps/web/src/pages/products/components/ProductAttributeSection.tsx +++ b/apps/web/src/pages/products/components/ProductAttributeSection.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd'; import type { FormInstance } from 'antd'; -import { EditOutlined } from '@ant-design/icons'; +import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons'; import type { ProductAttributeDictionaryValue, ProductAttributeSchema, @@ -37,6 +37,10 @@ type Props = { onOpenDictionaryValues: (dictionaryId: string) => void; onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void; onSyncAttributeSchema: () => void; + usesVariantTags: boolean; + usesVariantDescription: boolean; + onUseVariantTags: () => void; + onUseVariantDescription: () => void; }; /** @@ -62,6 +66,10 @@ export function ProductAttributeSection({ onOpenDictionaryValues, onLoadMoreDictionaryValues, onSyncAttributeSchema, + usesVariantTags, + usesVariantDescription, + onUseVariantTags, + onUseVariantDescription, }: Props) { // 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。 const [richContent, setRichContent] = useState(''); @@ -87,17 +95,41 @@ export function ProductAttributeSection({ title="产品属性" description="用于当前平台发布与 AI 文案生成。" /> - - + +