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

This commit is contained in:
R524809
2026-09-21 17:44:50 +08:00
parent 75fa2b60f3
commit 05747d6f22
20 changed files with 1006 additions and 244 deletions
@@ -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;
}
}
+41 -20
View File
@@ -137,6 +137,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
source_sku: variant.source_sku,
specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny,
tags: variant.tags,
description: variant.description,
physical_spec: variant.physical_spec,
package_spec: variant.package_spec,
offer_id: variant.listing.offer_id,
@@ -346,13 +348,12 @@ function millimetres(value: string): [string, string, string] | null {
: null;
}
/** 从采集属性中识别商品/包装各自的尺寸与重量字段,用于首次自动填充默认规格。 */
/** 从采集属性中识别包装尺寸与重量,用于首次自动填充 SPU 默认包装规格。 */
function detectedSpecs(attributes: CollectedAttribute[]) {
const result: Record<string, string> = {};
for (const attribute of attributes) {
const label = `${attribute.key} ${attribute.name}`.toLowerCase();
const isPackage = /package|packaging|parcel|shipping|包装|包裹|外箱/.test(label);
const isPhysical = /product|item|physical|商品|产品|实物/.test(label);
const isDimension = /dimension|size|length|width|height|尺寸|长宽高|规格/.test(label);
const isWeight = /weight|重量|质量/.test(label);
const dimensions = isDimension ? millimetres(attribute.value) : null;
@@ -361,12 +362,7 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
[result.package_length_mm, result.package_width_mm, result.package_height_mm] =
dimensions;
}
if (isPhysical && dimensions) {
[result.physical_length_mm, result.physical_width_mm, result.physical_height_mm] =
dimensions;
}
if (isPackage && weight) result.shipping_weight_g = weight;
if (isPhysical && weight) result.net_weight_g = weight;
}
return result;
}
@@ -425,6 +421,8 @@ const ProductEditPage = () => {
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
const [usesVariantTags, setUsesVariantTags] = useState(false);
const [usesVariantDescription, setUsesVariantDescription] = useState(false);
const blocker = useBlocker(dirty);
const sourceSpecsApplied = useRef(false);
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
@@ -469,6 +467,8 @@ const ProductEditPage = () => {
next.variants.length > 0 &&
next.variants.every((variant) => hasPackageDimensions(variant.package_spec))
);
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
setDirty(false);
} catch (error) {
message.error(apiErrorMessage(error));
@@ -663,18 +663,14 @@ const ProductEditPage = () => {
};
}, [currentPlatform, snapshot]);
const collected = sourceDraft ?? snapshot?.source_material ?? null;
// 首次拿到采集数据时自动填充默认规格:只补空字段,绝不覆盖已有值;
// 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值;
// 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。
// sourceSpecsApplied 保证整个页面生命周期只执行一次。
useEffect(() => {
if (sourceSpecsApplied.current || !collected) return;
const specs = detectedSpecs(collected.attributes);
const physical = form.getFieldValue(['common', 'default_physical_spec']);
const packageSpec = form.getFieldValue(['common', 'default_package_spec']);
const hasAppliedValue = [
[physical?.length_mm, specs.physical_length_mm],
[physical?.width_mm, specs.physical_width_mm],
[physical?.height_mm, specs.physical_height_mm],
[physical?.net_weight_g, specs.net_weight_g],
[packageSpec?.length_mm, specs.package_length_mm],
[packageSpec?.width_mm, specs.package_width_mm],
[packageSpec?.height_mm, specs.package_height_mm],
@@ -683,13 +679,6 @@ const ProductEditPage = () => {
if (hasAppliedValue) {
form.setFieldsValue({
common: {
default_physical_spec: {
...physical,
length_mm: physical?.length_mm ?? specs.physical_length_mm ?? null,
width_mm: physical?.width_mm ?? specs.physical_width_mm ?? null,
height_mm: physical?.height_mm ?? specs.physical_height_mm ?? null,
net_weight_g: physical?.net_weight_g ?? specs.net_weight_g ?? null,
},
default_package_spec: {
...packageSpec,
length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null,
@@ -775,6 +764,32 @@ const ProductEditPage = () => {
setUsesVariantPackageDimensions(true);
setDirty(true);
};
/** 将 SPU 主题标签复制到各 SKU;后续可在每个 SKU 内独立维护。 */
const useVariantTags = () => {
const tags = (form.getFieldValue(['profile', 'tags']) ?? []) as string[];
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
form.setFieldsValue({
variants: variants.map((variant) => ({
...variant,
tags: variant.tags.length ? variant.tags : tags,
})),
});
setUsesVariantTags(true);
setDirty(true);
};
/** 将 SPU 产品描述复制到各 SKU;后续可在每个 SKU 内独立维护。 */
const useVariantDescription = () => {
const description = form.getFieldValue(['profile', 'description']) as string | null;
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
form.setFieldsValue({
variants: variants.map((variant) => ({
...variant,
description: variant.description ?? description,
})),
});
setUsesVariantDescription(true);
setDirty(true);
};
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
const save = async (values: EditorFormValues) => {
if (!productId || !currentPlatform || !snapshot) return;
@@ -1073,6 +1088,10 @@ const ProductEditPage = () => {
onOpenDictionaryValues={openDictionaryValues}
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
onSyncAttributeSchema={syncAttributeSchema}
usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription}
onUseVariantTags={useVariantTags}
onUseVariantDescription={useVariantDescription}
/>
<ProductVariantSection
@@ -1093,6 +1112,8 @@ const ProductEditPage = () => {
onDirty={() => setDirty(true)}
usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions}
usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription}
/>
<ProductOtherSection
@@ -61,6 +61,11 @@
padding: 0;
font-size: 12px;
}
.collected-description-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.collected-field > strong {
display: block;
color: #352f43;
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd';
import type {
CollectionItemInspection,
@@ -50,6 +51,9 @@ export function CollectedProductInfoPanel({
onShowDifferences,
onAcceptLatest,
}: Props) {
const sourceDescription = collected?.description ?? '';
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
// 画廊与详情富文本里,转存记录会以不同角色各存一条)。
const imageGroups = (
@@ -131,11 +135,20 @@ export function CollectedProductInfoPanel({
)}
</div>
<Input.TextArea
value={collected?.description ?? ''}
value={descriptionDraft}
rows={12}
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
onChange={(event) => onUpdate('description', event.target.value)}
onChange={(event) => setDescriptionDraft(event.target.value)}
/>
<div className="collected-description-actions">
<Button
type="primary"
disabled={descriptionDraft === sourceDescription}
onClick={() => onUpdate('description', descriptionDraft)}
>
保存描述
</Button>
</div>
</div>
<div className="collected-field">
<span>采集基础数据</span>
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, InputNumber, Select, Typography } from 'antd';
import { Button, Form, Input, InputNumber, Select, Tooltip, Typography } from 'antd';
import type { FormInstance } from 'antd';
import { InfoCircleTwoTone } from '@ant-design/icons';
import type {
ProductAttributeDictionaryValue,
ProductAttributeSchema,
@@ -10,6 +11,97 @@ import type {
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
type NamePath = Array<string | number>;
const productColorValueLimit = 100;
/**
* Ozon 商品颜色字典(attribute_id=10096)的 value_id → SVG 色值与俄文颜色名。
* 数据来自 Ozon 卖家后台颜色下拉 DOM;键必须是 Ozon 字典值 ID,不能依赖会本地化或改名的显示文案。
*/
const ozonProductColorByValueId: Record<string, { color: string | null; russianName: string }> = {
'258411654': { color: '#806b2a', russianName: 'хаки' },
'970671251': { color: '#171717', russianName: 'матовый чёрный' },
'369939085': { color: null, russianName: 'мультиколор' },
'61584': { color: '#c3e6fe', russianName: 'небесно-голубой' },
'258411648': { color: '#fffdd0', russianName: 'кремовый' },
'61606': { color: '#422c16', russianName: 'шоколадный' },
'61603': { color: '#7e1e00', russianName: 'красно-коричневый' },
'61575': { color: '#924900', russianName: 'коричневый' },
'61605': { color: '#808000', russianName: 'оливковый' },
'61585': { color: '#ff9900', russianName: 'оранжевый' },
'61591': { color: '#cd853f', russianName: 'светло-коричневый' },
'61594': { color: '#cccccc', russianName: 'светло-серый' },
'61593': { color: '#e2d2c3', russianName: 'светло-бежевый' },
'61596': { color: '#ffcbde', russianName: 'светло-розовый' },
'971039569': { color: '#db7093', russianName: 'светло-фиолетовый' },
'61589': { color: '#89eb89', russianName: 'светло-зелёный' },
'971001201': { color: '#4040ff', russianName: 'светло-синий' },
'970673967': { color: '#f2f288', russianName: 'светло-жёлтый' },
'61588': { color: '#9c85cd', russianName: 'бледно-фиолетовый' },
'258411659': { color: '#7fff00', russianName: 'бледно-зелёный' },
'61598': { color: '#573120', russianName: 'тёмно-коричневый' },
'61600': { color: '#474a51', russianName: 'тёмно-серый' },
'61604': { color: '#9a7a65', russianName: 'тёмно-бежевый' },
'61611': { color: '#e75480', russianName: 'тёмно-розовый' },
'61602': { color: '#00541f', russianName: 'тёмно-зелёный' },
'61592': { color: '#00007e', russianName: 'тёмно-синий' },
'970832145': { color: '#4a1517', russianName: 'тёмно-бордовый' },
'61576': { color: '#666666', russianName: 'серый' },
'61601': { color: '#ef774b', russianName: 'коралловый' },
'971039568': { color: '#9c9c9c', russianName: 'перламутровый' },
'61571': { color: '#ffffff', russianName: 'белый' },
'61573': { color: '#c0987e', russianName: 'бежевый' },
'61580': { color: '#fd85b2', russianName: 'розовый' },
'61599': { color: '#f152db', russianName: 'пурпурный' },
'61586': { color: '#6600cc', russianName: 'фиолетовый' },
'970628491': { color: '#c400ab', russianName: 'красно-фиолетовый' },
'61579': { color: '#ff0000', russianName: 'красный' },
'970671252': { color: '#dc143c', russianName: 'ягодно-красный' },
'61583': { color: '#287a41', russianName: 'зелёный' },
'258411664': { color: '#ffdb58', russianName: 'горчичный' },
'61581': { color: '#0000ff', russianName: 'синий' },
'258411643': { color: '#007fff', russianName: 'лазурный' },
'61597': { color: '#fbf3e1', russianName: 'слоновая кость' },
'61572': { color: '#fffdfa', russianName: 'прозрачный' },
'61590': { color: '#9b2d30', russianName: 'бордовый' },
'61577': { color: '#b8b8b8', russianName: 'металлический серый' },
'61582': { color: '#ffd700', russianName: 'золотой' },
'61609': { color: '#b87333', russianName: 'медный' },
'970726613': { color: '#dbe4eb', russianName: 'хромовый' },
'61610': { color: '#c0c0c0', russianName: 'серебристый' },
'970693003': { color: '#fffdfa', russianName: 'зеркальный' },
'61595': { color: '#30d5c8', russianName: 'бирюзовый' },
'61587': { color: '#e6d690', russianName: 'бронзовый' },
'61578': { color: '#ffff00', russianName: 'жёлтый' },
'61607': { color: '#454545', russianName: 'чёрно-серый' },
'61574': { color: '#000000', russianName: 'чёрный' },
};
/** 商品颜色允许一个 SKU 同时选择多个颜色,作为类目单值配置的业务例外。 */
function isProductColor(definition: ProductAttributeSchema) {
return definition.name.trim() === '商品颜色';
}
/** 颜色展示严格按 Ozon 字典值 ID 取色,未知 ID 使用中性色以避免根据名称臆测。 */
function colorSwatch(valueId: string) {
const definition = ozonProductColorByValueId[valueId];
if (definition?.color === null) return { color: null, isMulticolor: true };
return { color: definition?.color ?? '#D0D5DD', isMulticolor: false };
}
/** 颜色字典的下拉选项与已选标签共用该展示,提交值始终保持字典 ID。 */
function ProductColorOption({ valueId, value }: { valueId: string; value: string }) {
const swatch = colorSwatch(valueId);
return (
<span className="product-color-option">
<span
className={`product-color-swatch${swatch.isMulticolor ? ' product-color-swatch--multicolor' : ''}`}
style={swatch.color ? { backgroundColor: swatch.color } : undefined}
aria-hidden
/>
<span>{value}</span>
</span>
);
}
type Props = {
form: FormInstance<EditorFormValues>;
@@ -23,6 +115,9 @@ type Props = {
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
emptyText?: string;
itemLayout?: 'horizontal' | 'vertical';
forceRequiredNames?: string[];
colorNameDefinition?: ProductAttributeSchema;
};
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
@@ -51,6 +146,12 @@ function DynamicAttributeField({
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
itemLayout,
forceRequiredNames,
form,
scope,
variantId,
colorNameDefinition,
}: {
definition: ProductAttributeSchema;
index: number;
@@ -60,6 +161,12 @@ function DynamicAttributeField({
onSyncDictionaryValues: Props['onSyncDictionaryValues'];
onOpenDictionaryValues: Props['onOpenDictionaryValues'];
onLoadMoreDictionaryValues: Props['onLoadMoreDictionaryValues'];
itemLayout: Props['itemLayout'];
forceRequiredNames: Props['forceRequiredNames'];
form: Props['form'];
scope: Props['scope'];
variantId: Props['variantId'];
colorNameDefinition: Props['colorNameDefinition'];
}) {
const [dictionaryQuery, setDictionaryQuery] = useState('');
const onSyncRef = useRef(onSyncDictionaryValues);
@@ -76,22 +183,87 @@ function DynamicAttributeField({
const valueName = ['attributes', index, 'values'] as NamePath;
const scalarValueName = [...valueName, 0] as NamePath;
const selectedProductColorValue = Form.useWatch(
['attributes', index, 'values'],
form
) as unknown;
const lastColorSelection = useRef<string | null>(null);
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
const isRequired =
definition.is_required || forceRequiredNames?.includes(definition.name.trim()) === true;
const maxValueCount = isProductColor(definition)
? productColorValueLimit
: definition.max_value_count;
const dictionaryOptions = definition.dictionary_id
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
value: value.value_id,
label: value.value,
label: isProductColor(definition) ? (
<ProductColorOption valueId={value.value_id} value={value.value} />
) : (
value.value
),
}))
: [];
const selectedColorValueIds = Array.isArray(selectedProductColorValue)
? selectedProductColorValue.filter((value): value is string => typeof value === 'string')
: typeof selectedProductColorValue === 'string'
? [selectedProductColorValue]
: [];
const colorSelectionKey = selectedColorValueIds.join('\u0000');
/** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */
const syncColorName = (selectedValue: unknown) => {
if (!isProductColor(definition) || !colorNameDefinition) return;
const valueIds = Array.isArray(selectedValue)
? selectedValue.filter((value): value is string => typeof value === 'string')
: typeof selectedValue === 'string'
? [selectedValue]
: [];
const russianNames = valueIds.flatMap((valueId) => {
const russianName = ozonProductColorByValueId[valueId]?.russianName;
return russianName ? [russianName] : [];
});
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
const colorNameIndex = attributeIndex(attributes, colorNameDefinition, scope, variantId);
if (colorNameIndex < 0) return;
form.setFieldValue(
['attributes', colorNameIndex, 'values'],
russianNames.length ? [russianNames.join(',')] : []
);
};
useEffect(() => {
if (!isProductColor(definition) || !colorNameDefinition) return;
if (lastColorSelection.current === null) {
lastColorSelection.current = colorSelectionKey;
return;
}
if (lastColorSelection.current === colorSelectionKey) return;
lastColorSelection.current = colorSelectionKey;
// 监听表单值作为兜底,覆盖远程字典选项更新或以编程方式修改商品颜色的场景。
syncColorName(selectedColorValueIds);
}, [
colorNameDefinition,
colorSelectionKey,
definition,
form,
scope,
selectedColorValueIds,
syncColorName,
variantId,
]);
const input =
definition.value_type === 'dictionary' ? (
<Form.Item name={definition.is_collection ? valueName : scalarValueName} noStyle>
<Form.Item name={allowsMultipleValues ? valueName : scalarValueName} noStyle>
<Select
mode={definition.is_collection ? 'multiple' : undefined}
maxCount={definition.is_collection ? definition.max_value_count : undefined}
className={isProductColor(definition) ? 'product-color-select' : undefined}
mode={allowsMultipleValues ? 'multiple' : undefined}
maxCount={allowsMultipleValues ? maxValueCount : undefined}
options={dictionaryOptions}
showSearch
allowClear
filterOption={false}
onChange={(value) => syncColorName(value)}
onOpenChange={(open) => {
if (!open || !definition.dictionary_id) return;
setDictionaryQuery('');
@@ -150,13 +322,13 @@ function DynamicAttributeField({
placeholder={`选择${definition.name}`}
/>
</Form.Item>
) : definition.is_collection ? (
) : allowsMultipleValues ? (
<Form.Item name={valueName} noStyle>
<Select
mode="tags"
maxCount={definition.max_value_count}
maxCount={maxValueCount}
tokenSeparators={[',']}
placeholder={`输入后回车添加,最多 ${definition.max_value_count} 项`}
placeholder={`输入后回车添加,最多 ${maxValueCount} 项`}
/>
</Form.Item>
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
@@ -175,12 +347,21 @@ function DynamicAttributeField({
return (
<Form.Item
label={definition.name}
required={definition.is_required}
className="dynamic-attribute-item"
extra={
definition.is_collection ? `最多填写 ${definition.max_value_count} 项` : undefined
label={
allowsMultipleValues ? (
<span className="dynamic-attribute-label">
<span>{definition.name}</span>
<Tooltip title={`最多填写 ${maxValueCount} 项`}>
<InfoCircleTwoTone aria-label={`最多填写 ${maxValueCount} 项`} />
</Tooltip>
</span>
) : (
definition.name
)
}
required={isRequired}
layout={itemLayout}
className="dynamic-attribute-item"
>
<div className="dynamic-attribute-control">{input}</div>
</Form.Item>
@@ -200,6 +381,9 @@ export function DynamicAttributeFields({
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
emptyText,
itemLayout,
forceRequiredNames,
colorNameDefinition,
}: Props) {
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
const fields = definitions.flatMap((definition) => {
@@ -223,6 +407,12 @@ export function DynamicAttributeFields({
onSyncDictionaryValues={onSyncDictionaryValues}
onOpenDictionaryValues={onOpenDictionaryValues}
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
itemLayout={itemLayout}
forceRequiredNames={forceRequiredNames}
form={form}
scope={scope}
variantId={variantId}
colorNameDefinition={colorNameDefinition}
/>
))}
</div>
@@ -54,9 +54,76 @@
white-space: normal;
word-break: break-word;
}
/* 多值字段把上限说明收进标题提示,避免额外说明行打乱 SKU 三列表单的节奏。 */
.dynamic-attribute-label {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.dynamic-attribute-label .anticon {
flex: 0 0 auto;
font-size: 14px;
}
.dynamic-attribute-control,
.dynamic-attribute-control .ant-select,
.dynamic-attribute-control .ant-input,
.dynamic-attribute-control .ant-input-number {
width: 100%;
}
/* SPU 字段启用 SKU 覆盖前显示圆形加号,控件与按钮保持紧邻。 */
.profile-override-control {
display: flex;
align-items: center;
gap: 4px;
}
.profile-override-control > .ant-form-item {
flex: 1 1 auto;
min-width: 0;
}
.profile-override-control--description {
align-items: flex-start;
}
/* 商品颜色字典只改变显示,表单仍保存 Ozon 字典值 ID。 */
.product-color-option {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.product-color-swatch {
flex: 0 0 auto;
width: 14px;
height: 14px;
box-sizing: border-box;
border: 1px solid rgb(71 84 103 / 45%);
border-radius: 50%;
}
/* “多色”没有单一颜色,使用空心圆表达而不伪造填充色。 */
.product-color-swatch--multicolor {
border-color: #667085;
background: transparent;
}
/* 多选标签保持单行控件的 32px 基准高度;多项换行时仍可按内容自然增高。 */
.product-color-select.ant-select-multiple .ant-select-selector {
box-sizing: border-box;
min-height: 32px;
padding-top: 1px;
padding-bottom: 1px;
}
.product-color-select .ant-select-selection-overflow {
align-items: center;
}
.product-color-select .ant-select-selection-item {
display: inline-flex;
height: 24px;
align-items: center;
margin-top: 2px;
margin-bottom: 2px;
line-height: 24px;
}
.product-color-select .product-color-option {
line-height: 20px;
}
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
import type { FormInstance } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons';
import type {
ProductAttributeDictionaryValue,
ProductAttributeSchema,
@@ -37,6 +37,10 @@ type Props = {
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncAttributeSchema: () => void;
usesVariantTags: boolean;
usesVariantDescription: boolean;
onUseVariantTags: () => void;
onUseVariantDescription: () => void;
};
/**
@@ -62,6 +66,10 @@ export function ProductAttributeSection({
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
onSyncAttributeSchema,
usesVariantTags,
usesVariantDescription,
onUseVariantTags,
onUseVariantDescription,
}: Props) {
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
const [richContent, setRichContent] = useState('');
@@ -87,17 +95,41 @@ export function ProductAttributeSection({
title="产品属性"
description="用于当前平台发布与 AI 文案生成。"
/>
<Form.Item name={['profile', 'tags']} label="主题标签">
<Select
className="editor-standard-select"
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加标签"
/>
</Form.Item>
<Form.Item name={['profile', 'description']} label="产品描述" rules={[{ max: 100000 }]}>
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
</Form.Item>
{!usesVariantTags && (
<Form.Item label="主题标签">
<div className="profile-override-control">
<Form.Item name={['profile', 'tags']} noStyle>
<Select
className="editor-standard-select"
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加标签"
/>
</Form.Item>
<Button
type="text"
icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写主题标签"
onClick={onUseVariantTags}
/>
</div>
</Form.Item>
)}
{!usesVariantDescription && (
<Form.Item label="产品描述" rules={[{ max: 100000 }]}>
<div className="profile-override-control profile-override-control--description">
<Form.Item name={['profile', 'description']} noStyle>
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
</Form.Item>
<Button
type="text"
icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写产品描述"
onClick={onUseVariantDescription}
/>
</div>
</Form.Item>
)}
<Form.Item name={['common', 'barcode']} label="条形码">
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
</Form.Item>
@@ -14,7 +14,7 @@
display: grid;
grid-template-columns:
minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px)
auto auto;
22px 32px;
align-items: center;
gap: 7px;
max-width: 100%;
@@ -26,8 +26,10 @@
text-align: center;
}
.package-dimension-unit {
width: 22px;
color: #667085;
line-height: 32px;
white-space: nowrap;
}
.package-dimensions-controls .ant-input-number {
width: 100%;
@@ -1,6 +1,6 @@
import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd';
import type { FormInstance } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { PlusCircleOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot } from '@atlas/contracts';
import { SectionTitle } from './SectionTitle';
import './ProductMainInfoSection.css';
@@ -42,7 +42,7 @@ function PackageFields({
<Tooltip title="按 SKU 分别填写包装重量">
<Button
type="text"
icon={<PlusOutlined />}
icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写包装重量"
onClick={onUseVariantPackageWeight}
/>
@@ -68,7 +68,7 @@ function PackageFields({
<Tooltip title="按 SKU 分别填写包装尺寸">
<Button
type="text"
icon={<PlusOutlined />}
icon={<PlusCircleOutlined />}
aria-label="按 SKU 分别填写包装尺寸"
onClick={onUseVariantPackageDimensions}
/>
@@ -118,7 +118,7 @@ export function ProductMainInfoSection({
<SectionTitle
number="01"
title="主要信息"
description="上架所需的核心资料。包装信息用于定价、物流与发布。"
description="上架所需的核心资料。默认包装规格用于定价、物流与发布,也可分别按 SKU 覆盖。"
/>
<div className="editor-form-grid">
<Form.Item
@@ -177,11 +177,6 @@
.sku-attribute-panel .ant-form-item-label > label {
justify-content: flex-start;
}
.sku-card-expanded .sku-attribute-panel--optional {
margin: 0;
padding: 0;
border: 0;
}
.sku-card-value strong {
min-height: 32px;
padding-top: 5px;
@@ -193,7 +188,7 @@
margin-left: auto;
}
/* 展开区:先展示 SKU 规格,再展示该 SKU 的图片选择区。 */
/* 展开区按图片、SKU 信息的顺序展示。 */
.sku-card-expanded {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -203,7 +198,7 @@
border-top: 1px solid #f0edf3;
}
.sku-media-panel,
.sku-spec-panel {
.sku-info-panel {
padding: 0;
border: 0;
border-radius: 0;
@@ -213,7 +208,7 @@
width: 100%;
}
.sku-media-panel h5.ant-typography,
.sku-spec-panel h5.ant-typography {
.sku-info-panel h5.ant-typography {
display: flex;
align-items: center;
gap: 12px;
@@ -224,8 +219,8 @@
}
.sku-media-panel h5.ant-typography::before,
.sku-media-panel h5.ant-typography::after,
.sku-spec-panel h5.ant-typography::before,
.sku-spec-panel h5.ant-typography::after {
.sku-info-panel h5.ant-typography::before,
.sku-info-panel h5.ant-typography::after {
content: '';
flex: 1;
border-top: 1px dashed #d9d4df;
@@ -300,32 +295,71 @@
grid-column: span 2;
grid-row: span 2;
}
.sku-spec-panel {
order: -1;
.sku-info-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.sku-spec-panel .ant-form-item {
margin-bottom: 10px;
.sku-info-panel h5.ant-typography,
.sku-info-panel > .ant-typography {
grid-column: 1 / -1;
}
.sku-spec-panel .ant-form-item-label {
flex: 0 0 78px !important;
.sku-info-panel .ant-form-item {
margin-bottom: 0;
}
/* SKU 信息保持标签在上、控件在下,独立于页面其他横向表单布局。 */
.sku-info-panel .ant-form-item-row {
display: block;
}
.sku-info-panel .ant-form-item-label {
display: block;
min-height: 26px;
padding: 0 0 4px;
text-align: left;
}
.sku-spec-panel .ant-form-item-label > label {
.sku-info-panel .ant-form-item-label > label {
align-items: center;
justify-content: flex-start;
font-weight: 600;
}
.sku-spec-panel .ant-form-item-control {
.sku-info-panel .ant-form-item-control {
display: block;
min-width: 0;
}
/* 独立的重量输入(非"长×宽×高"行内组)限制宽度 */
.sku-spec-panel
> .ant-form-item:not(:has(.sku-inline-dimensions))
:is(.ant-input-group-wrapper, .ant-input-number-group-wrapper) {
max-width: 220px;
.sku-info-package-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.sku-info-package-row .sku-info-package-dimensions {
grid-column: span 2;
}
.sku-info-variant-description {
grid-column: span 2;
}
/* 动态 SKU 属性也属于同一信息网格,不能再形成独立的横向属性区。 */
.sku-info-panel .dynamic-attribute-list {
display: contents;
}
.sku-info-panel .dynamic-attribute-item {
margin-bottom: 0;
}
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label {
display: block;
padding: 0 0 4px;
text-align: left;
}
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
align-items: center;
justify-content: flex-start;
text-align: left;
}
.sku-inline-dimensions {
display: grid;
grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px;
grid-template-columns:
minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr)
28px;
gap: 6px;
align-items: center;
}
@@ -359,4 +393,16 @@
.sku-inline-dimensions > span {
display: none;
}
.sku-info-panel {
grid-template-columns: 1fr;
}
.sku-info-package-row {
grid-template-columns: 1fr;
}
.sku-info-package-row .sku-info-package-dimensions {
grid-column: span 1;
}
.sku-info-variant-description {
grid-column: span 1;
}
}
@@ -7,6 +7,7 @@ import {
Input,
InputNumber,
Popconfirm,
Select,
Space,
Tag,
Typography,
@@ -91,6 +92,20 @@ function InlineDimensions({ name }: { name: NamePath }) {
);
}
/** Ozon 不同类目会使用“颜色名称”或“印刷名称”承载颜色的文字表达。 */
function isColorNameExpression(definition: ProductAttributeSchema) {
const label = definition.name.trim();
return label === '颜色名称' || label === '印刷名称';
}
/** 商品颜色和其文字表达是卖家识别 SKU 的首要字段,优先展示且保留其余 schema 顺序。 */
function skuInfoAttributeOrder(definition: ProductAttributeSchema) {
const label = definition.name.trim();
if (label === '商品颜色') return 0;
if (isColorNameExpression(definition)) return 1;
return 2;
}
type Props = {
form: FormInstance<EditorFormValues>;
snapshot: ProductEditorSnapshot;
@@ -107,11 +122,13 @@ type Props = {
onDirty: () => void;
usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean;
usesVariantTags: boolean;
usesVariantDescription: boolean;
};
/**
* 「03 变体设置」区块:SKU 卡片(名称/主图/货号/价格摘要)与展开后的
* 采集图片选择、包装与商品规格、规格轴编辑。
* 采集图片选择、SKU 包装覆盖与类目变体属性编辑。
*
* 采集证据(collectionRecord)与素材转存由页面处理;本组件维护卡片
* 展开状态与图片选择交互。
@@ -132,34 +149,45 @@ export function ProductVariantSection({
onDirty,
usesVariantPackageWeight,
usesVariantPackageDimensions,
usesVariantTags,
usesVariantDescription,
}: Props) {
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
const requiredAspectDefinitions = activeAttributeSchema.filter(
(definition) => definition.is_aspect && definition.is_required && !definition.editor_field
(definition) =>
definition.is_aspect &&
definition.is_required &&
definition.name.trim() !== '商品颜色' &&
!definition.editor_field
);
const optionalAspectDefinitions = activeAttributeSchema.filter(
(definition) => definition.is_aspect && !definition.is_required && !definition.editor_field
const skuInfoAspectDefinitions = activeAttributeSchema
.filter(
(definition) =>
definition.is_aspect &&
(!definition.is_required || definition.name.trim() === '商品颜色') &&
!definition.editor_field
)
.sort((left, right) => skuInfoAttributeOrder(left) - skuInfoAttributeOrder(right));
const colorNameDefinition = activeAttributeSchema.find(
(definition) =>
definition.is_aspect && !definition.editor_field && isColorNameExpression(definition)
);
return (
<Card className="editor-section sku-section" bordered={false}>
<SectionTitle
number="03"
title="变体设置"
description="卡片用于快速核对与填写;展开后可维护图片、规格轴和该 SKU 的覆盖规格。"
description="卡片用于快速核对与填写;展开后可维护图片和该 SKU 的包装覆盖。"
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
/>
<Form.List name="variants">
{(fields, { add, remove }) => {
// 新建变体默认值:id 为 null 表示服务端尚不存在,
// client_key 作为保存前的临时标识。
// 包装覆盖字段保持为空,发布时继承 SPU 默认值,避免持久化重复数据。
const addVariant = () => {
const defaultPackageSpec = form.getFieldValue([
'common',
'default_package_spec',
]) as EditorFormValues['common']['default_package_spec'];
add({
id: null,
client_key: crypto.randomUUID(),
@@ -168,6 +196,8 @@ export function ProductVariantSection({
source_sku: null,
specification_values: [],
purchase_cost_cny: null,
tags: [],
description: null,
physical_spec: {
length_mm: null,
width_mm: null,
@@ -176,18 +206,10 @@ export function ProductVariantSection({
status: null,
},
package_spec: {
length_mm: usesVariantPackageDimensions
? defaultPackageSpec.length_mm
: null,
width_mm: usesVariantPackageDimensions
? defaultPackageSpec.width_mm
: null,
height_mm: usesVariantPackageDimensions
? defaultPackageSpec.height_mm
: null,
shipping_weight_g: usesVariantPackageWeight
? defaultPackageSpec.shipping_weight_g
: null,
length_mm: null,
width_mm: null,
height_mm: null,
shipping_weight_g: null,
status: null,
},
offer_id: null,
@@ -438,36 +460,6 @@ export function ProductVariantSection({
)}
{expanded && (
<div className="sku-card-expanded">
{optionalAspectDefinitions.length > 0 &&
sourceId && (
<div className="sku-attribute-panel sku-attribute-panel--optional">
<Typography.Title level={5}>
Ozon 选填变体属性
</Typography.Title>
<DynamicAttributeFields
form={form}
definitions={
optionalAspectDefinitions
}
scope="variant"
variantId={sourceId}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={
onLoadDictionaryValues
}
onSyncDictionaryValues={
onSyncDictionaryValues
}
onOpenDictionaryValues={
onOpenDictionaryValues
}
onLoadMoreDictionaryValues={
onLoadMoreDictionaryValues
}
/>
</div>
)}
<div className="sku-media-panel">
<Typography.Title level={5}>
SKU 图片
@@ -573,103 +565,125 @@ export function ProductVariantSection({
</div>
)}
</div>
<div className="sku-spec-panel">
<Typography.Title level={5}>
SKU 规格
</Typography.Title>
{usesVariantPackageWeight && (
<Form.Item
name={[
field.name,
'package_spec',
'shipping_weight_g',
]}
label="包装重量"
>
<InputNumber<string>
min="0"
step="0.001"
stringMode
addonAfter="g"
/>
</Form.Item>
)}
{usesVariantPackageDimensions && (
<Form.Item label="包装尺寸">
<InlineDimensions
name={[field.name, 'package_spec']}
/>
</Form.Item>
)}
<Form.Item
name={[
field.name,
'physical_spec',
'net_weight_g',
]}
label="商品重量"
>
<InputNumber<string>
min="0"
step="0.001"
stringMode
addonAfter="g"
/>
</Form.Item>
<Form.Item label="商品尺寸">
<InlineDimensions
name={[field.name, 'physical_spec']}
/>
</Form.Item>
{/* 规格轴来自采集结果:颜色轴展示为"商品颜色" */}
<Form.List
name={[field.name, 'specification_values']}
>
{(specFields) =>
specFields.length ? (
<>
{specFields.map((specField) => {
const axisKey =
form.getFieldValue([
'variants',
{(usesVariantPackageWeight ||
usesVariantPackageDimensions ||
usesVariantTags ||
usesVariantDescription ||
(skuInfoAspectDefinitions.length > 0 &&
sourceId)) && (
<div className="sku-info-panel">
<Typography.Title level={5}>
SKU 信息
</Typography.Title>
{(usesVariantPackageWeight ||
usesVariantPackageDimensions) && (
<div className="sku-info-package-row">
{usesVariantPackageWeight && (
<Form.Item
name={[
field.name,
'package_spec',
'shipping_weight_g',
]}
label="包装重量"
required
layout="vertical"
className="sku-info-package-weight"
>
<InputNumber<string>
min="0"
step="0.001"
stringMode
addonAfter="g"
/>
</Form.Item>
)}
{usesVariantPackageDimensions && (
<Form.Item
label="包装尺寸"
required
layout="vertical"
className="sku-info-package-dimensions"
>
<InlineDimensions
name={[
field.name,
'specification_values',
specField.name,
'axis_key',
]) as
string | undefined;
const label =
/color|颜色/i.test(
axisKey ?? ''
)
? '商品颜色'
: axisKey ||
'商品规格';
return (
<Form.Item
key={specField.key}
name={[
specField.name,
'value_name',
]}
label={label}
>
<Input placeholder="未填写" />
</Form.Item>
);
})}
</>
) : (
<Form.Item label="商品颜色">
<Input
placeholder="未填写"
disabled
/>
</Form.Item>
)
}
</Form.List>
</div>
'package_spec',
]}
/>
</Form.Item>
)}
</div>
)}
{usesVariantTags && (
<Form.Item
name={[field.name, 'tags']}
label="主题标签"
layout="vertical"
className="sku-info-variant-tags"
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加标签"
/>
</Form.Item>
)}
{usesVariantDescription && (
<Form.Item
name={[field.name, 'description']}
label="产品描述"
layout="vertical"
className="sku-info-variant-description"
>
<Input.TextArea
rows={3}
placeholder="填写该 SKU 的产品描述"
/>
</Form.Item>
)}
{skuInfoAspectDefinitions.length > 0 &&
sourceId && (
<DynamicAttributeFields
form={form}
definitions={
skuInfoAspectDefinitions
}
scope="variant"
variantId={sourceId}
dictionaryValues={
dictionaryValues
}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={
onLoadDictionaryValues
}
onSyncDictionaryValues={
onSyncDictionaryValues
}
onOpenDictionaryValues={
onOpenDictionaryValues
}
onLoadMoreDictionaryValues={
onLoadMoreDictionaryValues
}
itemLayout="vertical"
forceRequiredNames={[
'商品颜色',
]}
colorNameDefinition={
colorNameDefinition
}
/>
)}
{(usesVariantPackageWeight ||
usesVariantPackageDimensions) && (
<Typography.Text type="secondary">
留空时使用 SPU 默认包装规格
</Typography.Text>
)}
</div>
)}
</div>
)}
</Card>