diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index c807934..44030ca 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -35,6 +35,25 @@ import './ProductEditPage.css'; type EditorFormValues = SaveProductEditorDraftRequest; type AttributeDraft = NonNullable[number]; +/** 规格字段有值时才可据此恢复到对应的 SKU 编辑模式。 */ +function hasPackageWeight(spec: { + length_mm: string | null; + width_mm: string | null; + height_mm: string | null; + shipping_weight_g: string | null; +}) { + return Boolean(spec.shipping_weight_g); +} + +function hasPackageDimensions(spec: { + length_mm: string | null; + width_mm: string | null; + height_mm: string | null; + shipping_weight_g: string | null; +}) { + return Boolean(spec.length_mm && spec.width_mm && spec.height_mm); +} + /** * 按 schema 补齐表单中缺失的属性草稿:aspect(变体维度)属性逐个 SKU 一条, * 其余类目级一条,确保 Form.List 字段与 schema 一一对应、可按定义渲染。 @@ -167,11 +186,11 @@ function categoryTree(options: ProductEditorCategoryOption[]) { for (const category of options) { const node = nodes.get(category.id)!; node.isLeaf = !node.children?.length; - node.disabled = node.isLeaf && ( - !category.is_active || - !category.external_type_id || - category.external_type_id === '0' - ); + node.disabled = + node.isLeaf && + (!category.is_active || + !category.external_type_id || + category.external_type_id === '0'); } for (const category of options) { const path: string[] = []; @@ -404,6 +423,8 @@ const ProductEditPage = () => { const [materializingTransferId, setMaterializingTransferId] = useState(null); const [dirty, setDirty] = useState(false); const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); + const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false); + const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false); const blocker = useBlocker(dirty); const sourceSpecsApplied = useRef(false); const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); @@ -440,6 +461,14 @@ const ProductEditPage = () => { setSourceOverriddenFields(next.source_material_state.overridden_fields); sourceSpecsApplied.current = false; form.setFieldsValue(formValues(next)); + setUsesVariantPackageWeight( + next.variants.length > 0 && + next.variants.every((variant) => hasPackageWeight(variant.package_spec)) + ); + setUsesVariantPackageDimensions( + next.variants.length > 0 && + next.variants.every((variant) => hasPackageDimensions(variant.package_spec)) + ); setDirty(false); } catch (error) { message.error(apiErrorMessage(error)); @@ -601,7 +630,9 @@ const ProductEditPage = () => { Object.fromEntries( results.map(([id, page]) => [ id, - dictionaryQueries.current[id] ? (current[id] ?? null) : page.next_cursor, + dictionaryQueries.current[id] + ? (current[id] ?? null) + : page.next_cursor, ]) ) ); @@ -615,13 +646,7 @@ const ProductEditPage = () => { return () => { active = false; }; - }, [ - catalogRefresh, - currentPlatform, - editableAttributeSchema, - selectedCategoryId, - snapshot, - ]); + }, [catalogRefresh, currentPlatform, editableAttributeSchema, selectedCategoryId, snapshot]); useEffect(() => { if (!snapshot || !currentPlatform) return; let active = true; @@ -703,6 +728,53 @@ const ProductEditPage = () => { }, [blocker, modal]); const leave = () => navigate('/collection'); + /** 将 SPU 共用包装重量复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */ + const useVariantPackageWeight = () => { + const defaultPackageSpec = form.getFieldValue([ + 'common', + 'default_package_spec', + ]) as EditorFormValues['common']['default_package_spec']; + const variants = form.getFieldValue('variants') as EditorFormValues['variants']; + form.setFieldsValue({ + variants: variants.map((variant) => { + const packageSpec = variant.package_spec; + return { + ...variant, + package_spec: { + ...packageSpec, + shipping_weight_g: + packageSpec.shipping_weight_g ?? defaultPackageSpec.shipping_weight_g, + }, + }; + }), + }); + setUsesVariantPackageWeight(true); + setDirty(true); + }; + /** 将 SPU 共用包装尺寸复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */ + const useVariantPackageDimensions = () => { + const defaultPackageSpec = form.getFieldValue([ + 'common', + 'default_package_spec', + ]) as EditorFormValues['common']['default_package_spec']; + const variants = form.getFieldValue('variants') as EditorFormValues['variants']; + form.setFieldsValue({ + variants: variants.map((variant) => { + const packageSpec = variant.package_spec; + return { + ...variant, + package_spec: { + ...packageSpec, + length_mm: packageSpec.length_mm ?? defaultPackageSpec.length_mm, + width_mm: packageSpec.width_mm ?? defaultPackageSpec.width_mm, + height_mm: packageSpec.height_mm ?? defaultPackageSpec.height_mm, + }, + }; + }), + }); + setUsesVariantPackageDimensions(true); + setDirty(true); + }; /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { if (!productId || !currentPlatform || !snapshot) return; @@ -836,7 +908,8 @@ const ProductEditPage = () => { if ( dictionaryContextRef.current !== requestContext || dictionaryQueries.current[attributeId] !== query - ) return; + ) + return; setDictionaryValues((current) => ({ ...current, [attributeId]: cursor @@ -976,6 +1049,10 @@ const ProductEditPage = () => { categoryPaths={categories.paths} categoryNotFoundContent={categoryNotFoundContent} onCategorySearch={setCategorySearch} + usesVariantPackageWeight={usesVariantPackageWeight} + usesVariantPackageDimensions={usesVariantPackageDimensions} + onUseVariantPackageWeight={useVariantPackageWeight} + onUseVariantPackageDimensions={useVariantPackageDimensions} /> { onOpenDictionaryValues={openDictionaryValues} onLoadMoreDictionaryValues={loadMoreDictionaryValues} onDirty={() => setDirty(true)} + usesVariantPackageWeight={usesVariantPackageWeight} + usesVariantPackageDimensions={usesVariantPackageDimensions} /> - ) : ( + ) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? ( - + step={definition.value_type === 'integer' ? '1' : '0.001'} + stringMode placeholder={`填写${definition.name}`} /> + ) : ( + + + ); return ( diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.css b/apps/web/src/pages/products/components/ProductAttributeSection.css index 079e417..92b9297 100644 --- a/apps/web/src/pages/products/components/ProductAttributeSection.css +++ b/apps/web/src/pages/products/components/ProductAttributeSection.css @@ -56,6 +56,7 @@ } .dynamic-attribute-control, .dynamic-attribute-control .ant-select, -.dynamic-attribute-control .ant-input { +.dynamic-attribute-control .ant-input, +.dynamic-attribute-control .ant-input-number { width: 100%; } diff --git a/apps/web/src/pages/products/components/ProductMainInfoSection.css b/apps/web/src/pages/products/components/ProductMainInfoSection.css index d56df6f..14740b9 100644 --- a/apps/web/src/pages/products/components/ProductMainInfoSection.css +++ b/apps/web/src/pages/products/components/ProductMainInfoSection.css @@ -12,10 +12,9 @@ /* 长×宽×高输入组:随容器宽度收缩(压缩到下限后再由窄屏断点改为纵向堆叠) */ .package-dimensions-controls { display: grid; - grid-template-columns: minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax( - 64px, - 130px - ) auto; + grid-template-columns: + minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px) + auto auto; align-items: center; gap: 7px; max-width: 100%; @@ -30,6 +29,14 @@ color: #667085; line-height: 32px; } +.package-dimensions-controls .ant-input-number { + width: 100%; +} +.package-weight-control { + display: flex; + align-items: center; + gap: 4px; +} /* 类目下拉弹层(Cascader popup,经 classNames.root 挂载) */ .editor-category-popup .ant-cascader-menu { diff --git a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx index e73b09f..fe920ad 100644 --- a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx +++ b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx @@ -1,5 +1,6 @@ -import { Card, Cascader, Form, Input, Select } from 'antd'; +import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd'; import type { FormInstance } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; import type { ProductEditorSnapshot } from '@atlas/contracts'; import { SectionTitle } from './SectionTitle'; import './ProductMainInfoSection.css'; @@ -13,28 +14,68 @@ type CategoryOption = { children?: CategoryOption[]; }; -function PackageFields({ name }: { name: NamePath }) { +function PackageFields({ + name, + usesVariantPackageWeight, + usesVariantPackageDimensions, + onUseVariantPackageWeight, + onUseVariantPackageDimensions, +}: { + name: NamePath; + usesVariantPackageWeight: boolean; + usesVariantPackageDimensions: boolean; + onUseVariantPackageWeight: () => void; + onUseVariantPackageDimensions: () => void; +}) { return (
- - - - -
- - - - × - - - - × - - - - mm -
-
+ {!usesVariantPackageWeight && ( + +
+ + min="0" + step="0.001" + stringMode + addonAfter="g" + style={{ width: 150 }} + /> + +
+
+ )} + {!usesVariantPackageDimensions && ( + +
+ + min="0" step="0.001" stringMode placeholder="长" /> + + × + + min="0" step="0.001" stringMode placeholder="宽" /> + + × + + min="0" step="0.001" stringMode placeholder="高" /> + + mm + +
+
+ )}
); } @@ -47,6 +88,10 @@ type Props = { categoryPaths: Map; categoryNotFoundContent: string; onCategorySearch: (query: string) => void; + usesVariantPackageWeight: boolean; + usesVariantPackageDimensions: boolean; + onUseVariantPackageWeight: () => void; + onUseVariantPackageDimensions: () => void; }; /** @@ -63,6 +108,10 @@ export function ProductMainInfoSection({ categoryPaths, categoryNotFoundContent, onCategorySearch, + usesVariantPackageWeight, + usesVariantPackageDimensions, + onUseVariantPackageWeight, + onUseVariantPackageDimensions, }: Props) { return ( @@ -139,7 +188,15 @@ export function ProductMainInfoSection({ - + {(!usesVariantPackageWeight || !usesVariantPackageDimensions) && ( + + )} ); } diff --git a/apps/web/src/pages/products/components/ProductVariantSection.css b/apps/web/src/pages/products/components/ProductVariantSection.css index e56c51a..6a1c22d 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.css +++ b/apps/web/src/pages/products/components/ProductVariantSection.css @@ -4,6 +4,10 @@ .sku-section .ant-input { border-radius: 8px; } +.sku-section .ant-input-number { + width: 100%; + border-radius: 8px; +} .sku-section textarea.ant-input { resize: none; } @@ -13,6 +17,9 @@ .sku-section .ant-input-group .ant-input { border-radius: 8px 0 0 8px; } +.sku-section .ant-input-number-group .ant-input-number { + border-radius: 8px 0 0 8px; +} /* 卡片列表与卡片骨架 */ .sku-card-list { @@ -83,7 +90,8 @@ width: 100%; } .sku-card-summary .ant-input, -.sku-card-summary textarea.ant-input { +.sku-card-summary textarea.ant-input, +.sku-card-summary .ant-input-number { width: 100%; } .sku-card-field, @@ -310,7 +318,9 @@ min-width: 0; } /* 独立的重量输入(非"长×宽×高"行内组)限制宽度 */ -.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper { +.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-inline-dimensions { diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index 9c73c2c..001d00a 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -1,5 +1,16 @@ import { useState } from 'react'; -import { Button, Card, Form, Image, Input, Popconfirm, Space, Tag, Typography } from 'antd'; +import { + Button, + Card, + Form, + Image, + Input, + InputNumber, + Popconfirm, + Space, + Tag, + Typography, +} from 'antd'; import type { FormInstance } from 'antd'; import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons'; import type { @@ -65,15 +76,15 @@ function InlineDimensions({ name }: { name: NamePath }) { return (
- + min="0" step="0.001" stringMode placeholder="长" /> × - + min="0" step="0.001" stringMode placeholder="宽" /> × - + min="0" step="0.001" stringMode placeholder="高" /> mm
@@ -94,6 +105,8 @@ type Props = { onOpenDictionaryValues: (dictionaryId: string) => void; onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void; onDirty: () => void; + usesVariantPackageWeight: boolean; + usesVariantPackageDimensions: boolean; }; /** @@ -117,6 +130,8 @@ export function ProductVariantSection({ onOpenDictionaryValues, onLoadMoreDictionaryValues, onDirty, + usesVariantPackageWeight, + usesVariantPackageDimensions, }: Props) { // 实时监听变体草稿:驱动主图回显与候选图片的选中态。 const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[]; @@ -140,7 +155,11 @@ export function ProductVariantSection({ {(fields, { add, remove }) => { // 新建变体默认值:id 为 null 表示服务端尚不存在, // client_key 作为保存前的临时标识。 - const addVariant = () => + const addVariant = () => { + const defaultPackageSpec = form.getFieldValue([ + 'common', + 'default_package_spec', + ]) as EditorFormValues['common']['default_package_spec']; add({ id: null, client_key: crypto.randomUUID(), @@ -157,15 +176,24 @@ export function ProductVariantSection({ status: null, }, package_spec: { - length_mm: null, - width_mm: null, - height_mm: null, - shipping_weight_g: null, + 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, status: null, }, offer_id: null, media_asset_ids: [], } satisfies VariantValues); + }; return (
{fields.map((field, index) => { @@ -331,7 +359,12 @@ export function ProductVariantSection({ }, ]} > - + + min="0" + step="0.0001" + stringMode + placeholder="—" + />
@@ -340,13 +373,15 @@ export function ProductVariantSection({ {original?.listing.currency_code ?? '₽'} ) - value={ original?.listing.selling_price ?? '' } placeholder="—" readOnly + controls={false} + stringMode />
@@ -542,26 +577,30 @@ export function ProductVariantSection({ SKU 规格 - - - - - - + {usesVariantPackageWeight && ( + + + min="0" + step="0.001" + stringMode + addonAfter="g" + /> + + )} + {usesVariantPackageDimensions && ( + + + + )} - min="0" - step="any" + step="0.001" + stringMode addonAfter="g" />