feat(edit): 编辑页样式修改

This commit is contained in:
R524809
2026-09-20 17:44:40 +08:00
parent e0e07be4ea
commit 75fa2b60f3
7 changed files with 279 additions and 86 deletions
+92 -13
View File
@@ -35,6 +35,25 @@ import './ProductEditPage.css';
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[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 ||
node.disabled =
node.isLeaf &&
(!category.is_active ||
!category.external_type_id ||
category.external_type_id === '0'
);
category.external_type_id === '0');
}
for (const category of options) {
const path: string[] = [];
@@ -404,6 +423,8 @@ const ProductEditPage = () => {
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(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}
/>
<ProductAttributeSection
@@ -1014,6 +1091,8 @@ const ProductEditPage = () => {
onOpenDictionaryValues={openDictionaryValues}
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
onDirty={() => setDirty(true)}
usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions}
/>
<ProductOtherSection
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, Select, Typography } from 'antd';
import { Button, Form, Input, InputNumber, Select, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type {
ProductAttributeDictionaryValue,
@@ -159,18 +159,18 @@ function DynamicAttributeField({
placeholder={`输入后回车添加,最多 ${definition.max_value_count} 项`}
/>
</Form.Item>
) : (
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
<Form.Item name={scalarValueName} noStyle>
<Input
type={
definition.value_type === 'integer' || definition.value_type === 'decimal'
? 'number'
: undefined
}
step={definition.value_type === 'integer' ? '1' : 'any'}
<InputNumber<string>
step={definition.value_type === 'integer' ? '1' : '0.001'}
stringMode
placeholder={`填写${definition.name}`}
/>
</Form.Item>
) : (
<Form.Item name={scalarValueName} noStyle>
<Input placeholder={`填写${definition.name}`} />
</Form.Item>
);
return (
@@ -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%;
}
@@ -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 {
@@ -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 (
<div className="package-fields">
{!usesVariantPackageWeight && (
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required>
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
<div className="package-weight-control">
<InputNumber<string>
min="0"
step="0.001"
stringMode
addonAfter="g"
style={{ width: 150 }}
/>
<Tooltip title="按 SKU 分别填写包装重量">
<Button
type="text"
icon={<PlusOutlined />}
aria-label="按 SKU 分别填写包装重量"
onClick={onUseVariantPackageWeight}
/>
</Tooltip>
</div>
</Form.Item>
)}
{!usesVariantPackageDimensions && (
<Form.Item label="包装尺寸" required className="package-dimensions">
<div className="package-dimensions-controls">
<Form.Item name={[...name, 'length_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="长" />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="长" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="宽" />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="宽" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="高" />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="高" />
</Form.Item>
<span className="package-dimension-unit">mm</span>
<Tooltip title="按 SKU 分别填写包装尺寸">
<Button
type="text"
icon={<PlusOutlined />}
aria-label="按 SKU 分别填写包装尺寸"
onClick={onUseVariantPackageDimensions}
/>
</Tooltip>
</div>
</Form.Item>
)}
</div>
);
}
@@ -47,6 +88,10 @@ type Props = {
categoryPaths: Map<string, string[]>;
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 (
<Card className="editor-section editor-horizontal-section" bordered={false}>
@@ -139,7 +188,15 @@ export function ProductMainInfoSection({
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
</Form.Item>
</div>
<PackageFields name={['common', 'default_package_spec']} />
{(!usesVariantPackageWeight || !usesVariantPackageDimensions) && (
<PackageFields
name={['common', 'default_package_spec']}
usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions}
onUseVariantPackageWeight={onUseVariantPackageWeight}
onUseVariantPackageDimensions={onUseVariantPackageDimensions}
/>
)}
</Card>
);
}
@@ -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 {
@@ -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 (
<div className="sku-inline-dimensions">
<Form.Item name={[...name, 'length_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="长" />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="长" />
</Form.Item>
<span className="sku-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="宽" />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="宽" />
</Form.Item>
<span className="sku-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="高" />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="高" />
</Form.Item>
<span className="sku-dimension-unit">mm</span>
</div>
@@ -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 (
<div className="sku-card-list">
{fields.map((field, index) => {
@@ -331,7 +359,12 @@ export function ProductVariantSection({
},
]}
>
<Input placeholder="—" />
<InputNumber<string>
min="0"
step="0.0001"
stringMode
placeholder="—"
/>
</Form.Item>
</div>
<div className="sku-card-field sku-price-field">
@@ -340,13 +373,15 @@ export function ProductVariantSection({
{original?.listing.currency_code ?? '₽'}
)
</span>
<Input
<InputNumber<string>
value={
original?.listing.selling_price ??
''
}
placeholder="—"
readOnly
controls={false}
stringMode
/>
</div>
<div className="sku-card-value">
@@ -542,6 +577,7 @@ export function ProductVariantSection({
<Typography.Title level={5}>
SKU 规格
</Typography.Title>
{usesVariantPackageWeight && (
<Form.Item
name={[
field.name,
@@ -550,18 +586,21 @@ export function ProductVariantSection({
]}
label="包装重量"
>
<Input
type="number"
<InputNumber<string>
min="0"
step="any"
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,
@@ -570,10 +609,10 @@ export function ProductVariantSection({
]}
label="商品重量"
>
<Input
type="number"
<InputNumber<string>
min="0"
step="any"
step="0.001"
stringMode
addonAfter="g"
/>
</Form.Item>