feat(edit): 商品编辑页开发
This commit is contained in:
@@ -1,5 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ProductAiSourceAttribute = {
|
||||
key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ProductAiSourceCategory = {
|
||||
key: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProductAiSourceMaterial = {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
price: { amount: string; currency_code: string } | null;
|
||||
brand: string | null;
|
||||
attributes: ProductAiSourceAttribute[];
|
||||
categories: ProductAiSourceCategory[];
|
||||
source_snapshot_revision: number;
|
||||
};
|
||||
|
||||
export type ProductAiSourceField =
|
||||
'title' | 'description' | 'price' | 'brand' | 'attributes' | 'categories';
|
||||
|
||||
export type ProductAiSourceMaterialState = {
|
||||
latest: ProductAiSourceMaterial;
|
||||
overridden_fields: ProductAiSourceField[];
|
||||
changed_fields: ProductAiSourceField[];
|
||||
base_source_snapshot_revision: number | null;
|
||||
has_source_updates: boolean;
|
||||
};
|
||||
|
||||
export type ProductAiInput = {
|
||||
source_material: ProductAiSourceMaterial;
|
||||
default_physical_spec: PhysicalSpec;
|
||||
default_package_spec: PackageSpec;
|
||||
variants: Array<{
|
||||
variant_id: string;
|
||||
display_name: string | null;
|
||||
specification_values: SpecificationValue[];
|
||||
effective_physical_spec: PhysicalSpec;
|
||||
effective_package_spec: PackageSpec;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ProductEditorCategoryOption = {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
external_category_id: string;
|
||||
external_type_id: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type ProductAttributeSchema = {
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
name: string;
|
||||
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
|
||||
is_required: boolean;
|
||||
is_collection: boolean;
|
||||
max_value_count: number;
|
||||
dictionary_id: string | null;
|
||||
is_aspect: boolean;
|
||||
};
|
||||
|
||||
export type ProductAttributeDictionaryValue = {
|
||||
value_id: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
||||
* publishing DTOs intentionally stay out of this first editor slice.
|
||||
@@ -13,6 +85,7 @@ export type ProductEditorContext = {
|
||||
};
|
||||
|
||||
export type ProductEditorSnapshot = {
|
||||
collection_item_id: string;
|
||||
product: {
|
||||
id: string;
|
||||
revision: number;
|
||||
@@ -24,6 +97,8 @@ export type ProductEditorSnapshot = {
|
||||
purchase_url: string | null;
|
||||
supply_note: string | null;
|
||||
};
|
||||
default_physical_spec: PhysicalSpec;
|
||||
default_package_spec: PackageSpec;
|
||||
};
|
||||
profile: {
|
||||
id: string;
|
||||
@@ -32,6 +107,11 @@ export type ProductEditorSnapshot = {
|
||||
market_code: string | null;
|
||||
fulfillment: string;
|
||||
target_store: { id: string; name: string; currency_code: string };
|
||||
available_target_stores: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
currency_code: string;
|
||||
}>;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
@@ -39,6 +119,12 @@ export type ProductEditorSnapshot = {
|
||||
id: string | null;
|
||||
platform_type_id: string | null;
|
||||
source: string | null;
|
||||
name: string | null;
|
||||
path: string | null;
|
||||
};
|
||||
listing_summary: {
|
||||
status: 'not_listed' | 'partially_listed' | 'listed';
|
||||
detail: string;
|
||||
};
|
||||
};
|
||||
variants: Array<{
|
||||
@@ -48,6 +134,11 @@ export type ProductEditorSnapshot = {
|
||||
display_name: string | null;
|
||||
sort_order: number;
|
||||
purchase_cost_cny: string | null;
|
||||
specification_values: SpecificationValue[];
|
||||
physical_spec: VariantPhysicalSpec;
|
||||
package_spec: VariantPackageSpec;
|
||||
effective_physical_spec: PhysicalSpec;
|
||||
effective_package_spec: PackageSpec;
|
||||
listing: {
|
||||
id: string;
|
||||
offer_id: string | null;
|
||||
@@ -80,45 +171,246 @@ export type ProductEditorSnapshot = {
|
||||
reason: string;
|
||||
status: string;
|
||||
}>;
|
||||
source_material: ProductAiSourceMaterial;
|
||||
source_material_state: ProductAiSourceMaterialState;
|
||||
ai_input: ProductAiInput;
|
||||
warnings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
|
||||
export type SpecStatus = 'missing' | 'ambiguous' | 'confirmed';
|
||||
export type PhysicalSpec = {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
net_weight_g: string | null;
|
||||
status: SpecStatus;
|
||||
};
|
||||
export type PackageSpec = {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
status: SpecStatus;
|
||||
};
|
||||
export type VariantPhysicalSpec = Omit<PhysicalSpec, 'status'> & {
|
||||
status: SpecStatus | null;
|
||||
};
|
||||
export type VariantPackageSpec = Omit<PackageSpec, 'status'> & {
|
||||
status: SpecStatus | null;
|
||||
};
|
||||
export type SpecificationValue = {
|
||||
axis_key: string;
|
||||
value_key: string;
|
||||
value_name: string;
|
||||
};
|
||||
|
||||
const uuid = z.uuid();
|
||||
const nullableText = (max: number) => z.string().trim().max(max).nullable();
|
||||
const nullableDecimal = z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,3})?$/)
|
||||
.refine((value) => Number(value) > 0, '必须大于 0')
|
||||
.nullable();
|
||||
const specStatus = z.enum(['missing', 'ambiguous', 'confirmed']);
|
||||
const physicalSpec = z
|
||||
.object({
|
||||
length_mm: nullableDecimal,
|
||||
width_mm: nullableDecimal,
|
||||
height_mm: nullableDecimal,
|
||||
net_weight_g: nullableDecimal,
|
||||
status: specStatus,
|
||||
})
|
||||
.strict();
|
||||
const packageSpec = z
|
||||
.object({
|
||||
length_mm: nullableDecimal,
|
||||
width_mm: nullableDecimal,
|
||||
height_mm: nullableDecimal,
|
||||
shipping_weight_g: nullableDecimal,
|
||||
status: specStatus,
|
||||
})
|
||||
.strict();
|
||||
const variantPhysicalSpec = physicalSpec
|
||||
.extend({ status: specStatus.nullable() })
|
||||
.strict();
|
||||
const variantPackageSpec = packageSpec
|
||||
.extend({ status: specStatus.nullable() })
|
||||
.strict();
|
||||
const specificationValue = z
|
||||
.object({
|
||||
axis_key: z.string().trim().min(1).max(100),
|
||||
value_key: z.string().trim().min(1).max(200),
|
||||
value_name: z.string().trim().min(1).max(300),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
||||
export const saveProductEditorDraftRequestSchema = z.object({
|
||||
common: z.object({
|
||||
source_title: nullableText(1_000),
|
||||
brand: nullableText(300),
|
||||
model: nullableText(300),
|
||||
barcode: nullableText(300),
|
||||
purchase_url: z.url().nullable(),
|
||||
supply_note: nullableText(4_000),
|
||||
}).strict(),
|
||||
profile: z.object({
|
||||
target_store_id: uuid,
|
||||
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
||||
const productAttributeDraft = z
|
||||
.object({
|
||||
scope: z.enum(['common', 'variant']),
|
||||
variant_id: uuid.nullable(),
|
||||
platform_attribute_id: z.string().trim().min(1).max(200),
|
||||
complex_id: z.string().trim().min(1).max(100),
|
||||
values: z.array(z.string().trim().min(1).max(2_000)).min(1).max(100),
|
||||
language_code: z.string().trim().min(2).max(20).nullable(),
|
||||
source: z
|
||||
.enum(['manual', 'source_capture', 'ai_suggestion'])
|
||||
.default('manual'),
|
||||
confirmation_status: z
|
||||
.enum(['missing', 'ambiguous', 'confirmed'])
|
||||
.default('confirmed'),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((attribute, context) => {
|
||||
if (attribute.scope === 'common' && attribute.variant_id !== null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['variant_id'],
|
||||
message: '通用属性不能关联 SKU',
|
||||
});
|
||||
}
|
||||
if (attribute.scope === 'variant' && attribute.variant_id === null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['variant_id'],
|
||||
message: 'SKU 属性必须关联 SKU',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const productAiSourceMaterialSchema = z
|
||||
.object({
|
||||
title: nullableText(1_000),
|
||||
description: nullableText(100_000),
|
||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||
}).strict(),
|
||||
variants: z.array(z.object({
|
||||
id: uuid.nullable(),
|
||||
client_key: z.string().trim().min(1).max(100).nullable(),
|
||||
copy_from_variant_id: uuid.nullable().optional(),
|
||||
display_name: nullableText(1_000),
|
||||
source_sku: nullableText(500),
|
||||
purchase_cost_cny: z.string().regex(/^\d+(\.\d{1,4})?$/).nullable(),
|
||||
offer_id: nullableText(500),
|
||||
}).strict().superRefine((variant, context) => {
|
||||
if (!variant.id && !variant.client_key) {
|
||||
context.addIssue({ code: 'custom', path: ['client_key'], message: '新 SKU 必须提供 client_key' });
|
||||
}
|
||||
})).min(1).max(100),
|
||||
}).strict();
|
||||
price: z
|
||||
.object({
|
||||
amount: z.string().regex(/^\d+(\.\d{1,4})?$/),
|
||||
currency_code: z.string().length(3).toUpperCase(),
|
||||
})
|
||||
.strict()
|
||||
.nullable(),
|
||||
brand: nullableText(300),
|
||||
attributes: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
key: z.string().trim().min(1).max(200),
|
||||
name: z.string().trim().min(1).max(300),
|
||||
value: z.string().max(2_000),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(500),
|
||||
categories: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
key: z.string().trim().min(1).max(300),
|
||||
name: z.string().trim().min(1).max(500),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(50),
|
||||
source_snapshot_revision: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SaveProductEditorDraftRequest = z.infer<typeof saveProductEditorDraftRequestSchema>;
|
||||
export const productAiSourceFieldSchema = z.enum([
|
||||
'title',
|
||||
'description',
|
||||
'price',
|
||||
'brand',
|
||||
'attributes',
|
||||
'categories',
|
||||
]);
|
||||
|
||||
export const productAiSourceMaterialOverridesSchema =
|
||||
productAiSourceMaterialSchema
|
||||
.omit({ source_snapshot_revision: true })
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
||||
export const saveProductEditorDraftRequestSchema = z
|
||||
.object({
|
||||
ai_source_material: productAiSourceMaterialSchema,
|
||||
ai_source_overridden_fields: z
|
||||
.array(productAiSourceFieldSchema)
|
||||
.max(6)
|
||||
.refine(
|
||||
(fields) => new Set(fields).size === fields.length,
|
||||
'覆盖字段不能重复',
|
||||
),
|
||||
common: z
|
||||
.object({
|
||||
source_title: nullableText(1_000),
|
||||
brand: nullableText(300),
|
||||
model: nullableText(300),
|
||||
barcode: nullableText(300),
|
||||
purchase_url: z.url().nullable(),
|
||||
supply_note: nullableText(4_000),
|
||||
default_physical_spec: physicalSpec,
|
||||
default_package_spec: packageSpec,
|
||||
})
|
||||
.strict(),
|
||||
profile: z
|
||||
.object({
|
||||
target_store_id: uuid,
|
||||
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
||||
title: nullableText(1_000),
|
||||
description: nullableText(100_000),
|
||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||
platform_product_category_id: uuid.nullable().optional(),
|
||||
category_source: z
|
||||
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
attributes: z.array(productAttributeDraft).max(1_000).optional(),
|
||||
variants: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: uuid.nullable(),
|
||||
client_key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.nullable(),
|
||||
copy_from_variant_id: uuid.nullable().optional(),
|
||||
display_name: nullableText(1_000),
|
||||
source_sku: nullableText(500),
|
||||
specification_values: z
|
||||
.array(specificationValue)
|
||||
.max(20),
|
||||
purchase_cost_cny: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,4})?$/)
|
||||
.nullable(),
|
||||
physical_spec: variantPhysicalSpec,
|
||||
package_spec: variantPackageSpec,
|
||||
offer_id: nullableText(500),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((variant, context) => {
|
||||
if (!variant.id && !variant.client_key) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['client_key'],
|
||||
message: '新 SKU 必须提供 client_key',
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SaveProductEditorDraftRequest = z.infer<
|
||||
typeof saveProductEditorDraftRequestSchema
|
||||
>;
|
||||
export type SaveProductEditorDraftResponse = {
|
||||
product_revision: number;
|
||||
profile_revision: number;
|
||||
|
||||
Reference in New Issue
Block a user