feat(edit): 商品编辑页开发

This commit is contained in:
R524809
2026-09-10 17:45:46 +08:00
parent 130a005231
commit 946af649dd
22 changed files with 7034 additions and 518 deletions
+322 -30
View File
@@ -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;
@@ -0,0 +1,10 @@
SET search_path TO atlas, public;
-- M3.3 keeps the user-curated AI source material beside the product aggregate.
-- The original collection normalized_payload/raw_evidence remains immutable
-- evidence; this nullable document is initialized from that evidence on read
-- and becomes durable only after the user saves the editor draft.
ALTER TABLE products
ADD COLUMN ai_source_material JSONB,
ADD CONSTRAINT ck_products_ai_source_material_object
CHECK (ai_source_material IS NULL OR jsonb_typeof(ai_source_material) = 'object');
@@ -0,0 +1,40 @@
SET search_path TO atlas, public;
-- M3.3.1 evolves the full AI source snapshot into field-level overrides.
-- Preserve any document saved between migrations 019 and 020 by treating all
-- of its material fields as explicit overrides based on its recorded source
-- snapshot revision.
ALTER TABLE products
ADD COLUMN ai_source_material_base_revision BIGINT
CHECK (ai_source_material_base_revision IS NULL OR ai_source_material_base_revision > 0);
-- The migrator owns the table, but products uses FORCE RLS. Temporarily turn
-- it off inside this atomic migration so existing rows from every workspace
-- can be transformed; the table lock prevents application writes in between.
ALTER TABLE products NO FORCE ROW LEVEL SECURITY;
ALTER TABLE products DISABLE ROW LEVEL SECURITY;
UPDATE products
SET ai_source_material_base_revision =
CASE
WHEN ai_source_material ? 'source_snapshot_revision'
AND (ai_source_material->>'source_snapshot_revision') ~ '^[1-9][0-9]*$'
THEN (ai_source_material->>'source_snapshot_revision')::BIGINT
ELSE NULL
END,
ai_source_material = ai_source_material - 'source_snapshot_revision'
WHERE ai_source_material IS NOT NULL;
ALTER TABLE products RENAME COLUMN ai_source_material TO ai_source_material_overrides;
ALTER TABLE products
ALTER COLUMN ai_source_material_overrides SET DEFAULT '{}'::jsonb;
UPDATE products
SET ai_source_material_overrides = '{}'::jsonb
WHERE ai_source_material_overrides IS NULL;
ALTER TABLE products
ALTER COLUMN ai_source_material_overrides SET NOT NULL;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE products FORCE ROW LEVEL SECURITY;
@@ -0,0 +1,46 @@
SET search_path TO atlas, public;
-- M3.4 stores the platform's cached category attribute contract separately
-- from workspace drafts. Product attributes always reference IDs from this
-- schema; dictionary values are a cache, never free-form client metadata.
CREATE TABLE platform_product_category_attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
platform_product_category_id UUID NOT NULL,
platform_attribute_id TEXT NOT NULL,
complex_id TEXT NOT NULL DEFAULT '0',
name TEXT NOT NULL,
value_type TEXT NOT NULL CHECK (value_type IN ('text', 'integer', 'decimal', 'boolean', 'dictionary')),
is_required BOOLEAN NOT NULL DEFAULT false,
is_collection BOOLEAN NOT NULL DEFAULT false,
max_value_count INTEGER NOT NULL DEFAULT 1 CHECK (max_value_count BETWEEN 1 AND 100),
dictionary_id TEXT,
is_aspect BOOLEAN NOT NULL DEFAULT false,
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (platform_id, platform_product_category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
UNIQUE (platform_id, platform_product_category_id, platform_attribute_id, complex_id),
CHECK ((value_type = 'dictionary') = (dictionary_id IS NOT NULL)),
CHECK (is_collection OR max_value_count = 1)
);
CREATE INDEX ix_platform_product_category_attributes_lookup
ON platform_product_category_attributes (platform_id, platform_product_category_id, is_required DESC, name, id);
CREATE TABLE platform_attribute_dictionary_values (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
dictionary_id TEXT NOT NULL,
external_value_id TEXT NOT NULL,
value TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (platform_id, dictionary_id, external_value_id)
);
CREATE INDEX ix_platform_attribute_dictionary_values_search
ON platform_attribute_dictionary_values (platform_id, dictionary_id, is_active, value, external_value_id);