diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 72a142fc..e8a83d28 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -1942,6 +1942,9 @@ function getPopulatedPlanningLayoutRowCountForList( row.priceBand, row.brand ?? "", row.shop, + ...(row.styles ?? []), + ...(row.styleTones ?? []), + ...(row.audiences ?? []), ...row.brands, ...row.spaces, ...row.categories, diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 79d4d4e1..5307729c 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -3842,7 +3842,7 @@ describe("NonIntegratedDetailClient", () => { screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`), ); expect( - screen.getByTestId("planning-layout-style-sku-planning-permission-1"), + screen.getByTestId("planning-layout-styles-draft-sku-planning-permission-1"), ).not.toHaveAttribute("readonly"); expect( screen.getByTestId(`document-workspace-confirm-${planningLayoutCategory}`), @@ -4503,20 +4503,45 @@ describe("NonIntegratedDetailClient", () => { ).toHaveValue("500-600"); fireEvent.change( - screen.getByTestId("planning-layout-style-sku-planning-layout-fields-1"), + screen.getByTestId("planning-layout-styles-draft-sku-planning-layout-fields-1"), { target: { value: "治愈风" } }, ); - await waitFor(() => - expect( - screen.getByTestId( - "planning-layout-styleTone-sku-planning-layout-fields-1", - ), - ).toHaveValue("原木色自然&治愈风"), - ); + expect(within(planningWorkspace).getByText("治愈风")).toBeInTheDocument(); fireEvent.change( - screen.getByTestId("planning-layout-audience-sku-planning-layout-fields-1"), + screen.getByTestId("planning-layout-styles-draft-sku-planning-layout-fields-1"), + { target: { value: "日系风" } }, + ); + expect(within(planningWorkspace).getByText("日系风")).toBeInTheDocument(); + fireEvent.change( + screen.getByTestId( + "planning-layout-styleTones-draft-sku-planning-layout-fields-1", + ), + { target: { value: "原木色自然&治愈风" } }, + ); + expect( + within(planningWorkspace).getByText("原木色自然&治愈风"), + ).toBeInTheDocument(); + fireEvent.change( + screen.getByTestId( + "planning-layout-styleTones-draft-sku-planning-layout-fields-1", + ), + { target: { value: "侘寂风" } }, + ); + expect(within(planningWorkspace).getByText("侘寂风")).toBeInTheDocument(); + fireEvent.change( + screen.getByTestId( + "planning-layout-audiences-draft-sku-planning-layout-fields-1", + ), { target: { value: "有孩家庭" } }, ); + expect(within(planningWorkspace).getByText("有孩家庭")).toBeInTheDocument(); + fireEvent.change( + screen.getByTestId( + "planning-layout-audiences-draft-sku-planning-layout-fields-1", + ), + { target: { value: "独居青年" } }, + ); + expect(within(planningWorkspace).getByText("独居青年")).toBeInTheDocument(); fireEvent.change( screen.getByTestId("planning-layout-model-sku-planning-layout-fields-1"), { target: { value: "窄边书桌" } }, @@ -4594,8 +4619,11 @@ describe("NonIntegratedDetailClient", () => { { skuId: "sku-planning-layout-fields-1", style: "治愈风", + styles: ["治愈风", "日系风"], styleTone: "原木色自然&治愈风", + styleTones: ["原木色自然&治愈风", "侘寂风"], audience: "有孩家庭", + audiences: ["有孩家庭", "独居青年"], spaces: ["客厅", "书房"], categories: ["书柜/架", "书桌/电脑桌"], model: "窄边书桌", diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index 093e8d95..56047990 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -2375,20 +2375,8 @@ const PLANNING_LAYOUT_SPACE_CATEGORY_OPTIONS: Record = { 卫生间: ["洗脸池柜", "镜柜", "镜子", "置物架", "毛巾架", "矮凳"], 厨房: ["桌面置物架", "落地置物架", "储物柜", "矮凳", "推车", "桌面收纳架"], }; -const PLANNING_LAYOUT_MODEL_OPTIONS = [ - "窄边书桌", - "开放式置物架", - "折叠翻板桌", - "移动推车", - "边柜组合", -]; -const PLANNING_LAYOUT_SHOP_OPTIONS = [ - "天猫旗舰店", - "抖音旗舰店", - "京东自营", - "拼多多店铺", - "线下门店", -]; +const PLANNING_LAYOUT_MODEL_OPTIONS: string[] = []; +const PLANNING_LAYOUT_SHOP_OPTIONS: string[] = []; const SINGLE_VALUE_BRAND_FIELD_KEY = "brand"; function isSingleValueBrandField(key: PropertyKey) { @@ -5217,8 +5205,11 @@ type PlanningLayoutSkuRow = { supplierName: string; batchNo: number; style: string; + styles: string[]; styleTone: string; + styleTones: string[]; audience: string; + audiences: string[]; spaces: string[]; categories: string[]; planningModel: string; @@ -5227,14 +5218,14 @@ type PlanningLayoutSkuRow = { shop: string; }; -type PlanningLayoutTextFieldKey = - | "style" - | "styleTone" - | "audience" - | "model" - | "priceBand" - | "shop"; -type PlanningLayoutListFieldKey = "spaces" | "categories" | "brands"; +type PlanningLayoutTextFieldKey = "model" | "priceBand" | "shop"; +type PlanningLayoutListFieldKey = + | "styles" + | "styleTones" + | "audiences" + | "spaces" + | "categories" + | "brands"; type ProductLimitDisplayRow = NonIntegratedProductLimitRow & { image: string; @@ -5348,8 +5339,11 @@ function createEmptyPlanningLayoutRow( return { skuId, style: "", + styles: [], styleTone: "", + styleTones: [], audience: "", + audiences: [], spaces: [], categories: [], model: "", @@ -5360,6 +5354,18 @@ function createEmptyPlanningLayoutRow( }; } +function getPlanningLayoutStyleToneOptions( + styles: ReadonlyArray, + styleTones: ReadonlyArray = [], +) { + return getUniqueNonEmptyStrings([ + ...styles.flatMap( + (style) => PLANNING_LAYOUT_STYLE_TONE_OPTIONS[style] ?? [], + ), + ...styleTones, + ]); +} + function normalizeProductLimitRowsForPayload( rows: NonIntegratedProductLimitRow[], ): NonIntegratedProductLimitRow[] { @@ -5396,12 +5402,27 @@ function normalizePlanningLayoutRowsForPayload( ...row.brands, row.brand, ]); + const styles = getUniqueNonEmptyStrings([ + ...(row.styles ?? []), + row.style, + ]); + const styleTones = getUniqueNonEmptyStrings([ + ...(row.styleTones ?? []), + row.styleTone, + ]); + const audiences = getUniqueNonEmptyStrings([ + ...(row.audiences ?? []), + row.audience, + ]); return { skuId: sku.id, - style: row.style.trim(), - styleTone: row.styleTone.trim(), - audience: row.audience.trim(), + style: styles[0] ?? "", + styles, + styleTone: styleTones[0] ?? "", + styleTones, + audience: audiences[0] ?? "", + audiences, spaces: getUniqueNonEmptyStrings(row.spaces), categories: getUniqueNonEmptyStrings(row.categories), model: row.model.trim(), @@ -5423,6 +5444,9 @@ function normalizePlanningLayoutRowsForPayload( row.priceBand, row.brand ?? "", row.shop, + ...row.styles, + ...row.styleTones, + ...row.audiences, ...row.spaces, ...row.categories, ...row.brands, @@ -5476,8 +5500,20 @@ function buildPlanningLayoutSkuRows( supplierName: item.manufacturer.trim(), batchNo: item.batchNo || 1, style: row?.style.trim() ?? "", + styles: getUniqueNonEmptyStrings([ + ...(row?.styles ?? []), + row?.style, + ]), styleTone: row?.styleTone.trim() ?? "", + styleTones: getUniqueNonEmptyStrings([ + ...(row?.styleTones ?? []), + row?.styleTone, + ]), audience: row?.audience.trim() ?? "", + audiences: getUniqueNonEmptyStrings([ + ...(row?.audiences ?? []), + row?.audience, + ]), spaces: row?.spaces ?? [], categories: row?.categories ?? [], planningModel: row?.model.trim() ?? "", @@ -14896,6 +14932,48 @@ export default function NonIntegratedDetailClient({ })); } + function applyPlanningLayoutListValues( + current: NonIntegratedPlanningLayoutRow, + field: PlanningLayoutListFieldKey, + values: string[], + ): NonIntegratedPlanningLayoutRow { + const nextValues = getUniqueNonEmptyStrings(values); + + if (field === "styles") { + return { + ...current, + style: nextValues[0] ?? "", + styles: nextValues, + }; + } + if (field === "styleTones") { + return { + ...current, + styleTone: nextValues[0] ?? "", + styleTones: nextValues, + }; + } + if (field === "audiences") { + return { + ...current, + audience: nextValues[0] ?? "", + audiences: nextValues, + }; + } + if (field === "brands") { + return { + ...current, + brand: nextValues[0] ?? "", + brands: nextValues, + }; + } + + return { + ...current, + [field]: nextValues, + }; + } + function addPlanningLayoutListValue( skuId: string, field: PlanningLayoutListFieldKey, @@ -14911,10 +14989,16 @@ export default function NonIntegratedDetailClient({ .filter(Boolean); if (nextValues.length === 0) return; - updatePlanningLayoutRow(skuId, (current) => ({ - ...current, - [field]: getUniqueNonEmptyStrings([...current[field], ...nextValues]), - })); + updatePlanningLayoutRow(skuId, (current) => { + const currentValues = Array.isArray(current[field]) + ? current[field] + : []; + + return applyPlanningLayoutListValues(current, field, [ + ...currentValues, + ...nextValues, + ]); + }); if (explicitValue === undefined) { setPlanningLayoutDraftInputs((current) => ({ ...current, @@ -14930,10 +15014,17 @@ export default function NonIntegratedDetailClient({ ) { if (isReadOnly) return; - updatePlanningLayoutRow(skuId, (current) => ({ - ...current, - [field]: current[field].filter((item) => item !== value), - })); + updatePlanningLayoutRow(skuId, (current) => { + const currentValues = Array.isArray(current[field]) + ? current[field] + : []; + + return applyPlanningLayoutListValues( + current, + field, + currentValues.filter((item) => item !== value), + ); + }); } function togglePlanningLayoutBrand(skuId: string, brand: string) { @@ -21155,43 +21246,43 @@ export default function NonIntegratedDetailClient({ 批次{row.batchNo} - {renderSearchCell({ + {renderMultiSelectCell({ row, - field: "style", + field: "styles", label: "风格", - value: row.style, - options: PLANNING_LAYOUT_STYLE_OPTIONS, - onChange: (value) => - updatePlanningLayoutRow(row.skuId, (current) => ({ - ...current, - style: value, - styleTone: - PLANNING_LAYOUT_STYLE_TONE_OPTIONS[value]?.[0] ?? - current.styleTone, - })), - })} - - - {renderSearchCell({ - row, - field: "styleTone", - label: "风格细分/色系", - value: row.styleTone, + values: row.styles ?? [], options: getUniqueNonEmptyStrings([ - ...(PLANNING_LAYOUT_STYLE_TONE_OPTIONS[row.style] ?? - []), - row.styleTone, + ...PLANNING_LAYOUT_STYLE_OPTIONS, + ...(row.styles ?? []), ]), - minWidth: "180px", + autoAddOnChange: true, })} - {renderSearchCell({ + {renderMultiSelectCell({ row, - field: "audience", + field: "styleTones", + label: "风格细分/色系", + values: row.styleTones ?? [], + options: getPlanningLayoutStyleToneOptions( + row.styles ?? [], + row.styleTones ?? [], + ), + minWidth: "180px", + autoAddOnChange: true, + })} + + + {renderMultiSelectCell({ + row, + field: "audiences", label: "适用人群", - value: row.audience, - options: PLANNING_LAYOUT_AUDIENCE_OPTIONS, + values: row.audiences ?? [], + options: getUniqueNonEmptyStrings([ + ...PLANNING_LAYOUT_AUDIENCE_OPTIONS, + ...(row.audiences ?? []), + ]), + autoAddOnChange: true, })} diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts index fc174d43..ca67571f 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts @@ -221,6 +221,43 @@ describe('nonIntegratedStorage', () => { }) }) + it('normalizes legacy planning layout single-select fields into multi-select fields', () => { + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + ...createProduct(), + planningLayoutRows: [ + { + skuId: 'sku-1', + style: '中古风', + styleTone: '浅色系(轻法式)', + audience: '有孩家庭', + spaces: ['书房'], + categories: ['书桌/电脑桌'], + model: '款式一', + priceBand: '500-600', + brand: '基本盘', + brands: ['顾家'], + shop: '天猫', + }, + ], + }, + ]), + ) + + const savedProduct = getNonIntegratedProductById('product-1') + + expect(savedProduct?.planningLayoutRows[0]).toMatchObject({ + style: '中古风', + styles: ['中古风'], + styleTone: '浅色系(轻法式)', + styleTones: ['浅色系(轻法式)'], + audience: '有孩家庭', + audiences: ['有孩家庭'], + }) + }) + it('keeps bom details when saving the local record', () => { saveNonIntegratedProduct( createProduct({ diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts index 732b329c..5fedf76f 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts @@ -372,8 +372,11 @@ export type NonIntegratedProductLimitRow = { export type NonIntegratedPlanningLayoutRow = { skuId: string; style: string; + styles: string[]; styleTone: string; + styleTones: string[]; audience: string; + audiences: string[]; spaces: string[]; categories: string[]; model: string; @@ -762,6 +765,18 @@ function normalizeString(value: unknown) { return typeof value === "string" ? value : ""; } +function normalizeStringList(values: unknown) { + if (!Array.isArray(values)) return []; + + return Array.from( + new Set( + values + .map((value) => normalizeString(value).trim()) + .filter((value) => value.length > 0), + ), + ); +} + export function normalizeNonIntegratedProjectReview( value: unknown, ): NonIntegratedProjectReview { @@ -1288,6 +1303,24 @@ function normalizePlanningLayoutRow( item: Partial, ): NonIntegratedPlanningLayoutRow | null { if (typeof item.skuId !== "string") return null; + const normalizedStyles = Array.from( + new Set([ + ...normalizeStringList(item.styles), + normalizeString(item.style).trim(), + ].filter((value) => value.length > 0)), + ); + const normalizedStyleTones = Array.from( + new Set([ + ...normalizeStringList(item.styleTones), + normalizeString(item.styleTone).trim(), + ].filter((value) => value.length > 0)), + ); + const normalizedAudiences = Array.from( + new Set([ + ...normalizeStringList(item.audiences), + normalizeString(item.audience).trim(), + ].filter((value) => value.length > 0)), + ); const normalizedBrands = [ ...(Array.isArray(item.brands) ? item.brands : []), normalizeString(item.brand), @@ -1297,19 +1330,14 @@ function normalizePlanningLayoutRow( return { skuId: item.skuId, - style: normalizeString(item.style), - styleTone: normalizeString(item.styleTone), - audience: normalizeString(item.audience), - spaces: Array.isArray(item.spaces) - ? item.spaces - .map((value) => normalizeString(value)) - .filter((value) => value.length > 0) - : [], - categories: Array.isArray(item.categories) - ? item.categories - .map((value) => normalizeString(value)) - .filter((value) => value.length > 0) - : [], + style: normalizedStyles[0] ?? "", + styles: normalizedStyles, + styleTone: normalizedStyleTones[0] ?? "", + styleTones: normalizedStyleTones, + audience: normalizedAudiences[0] ?? "", + audiences: normalizedAudiences, + spaces: normalizeStringList(item.spaces), + categories: normalizeStringList(item.categories), model: normalizeString(item.model), priceBand: normalizeString(item.priceBand), brand: normalizedBrands[0] ?? "", @@ -2353,6 +2381,9 @@ export function createCopiedNonIntegratedProduct( pricingRows: [], planningLayoutRows: sourceProduct.planningLayoutRows.map((row) => ({ ...row, + styles: [...(row.styles ?? [])], + styleTones: [...(row.styleTones ?? [])], + audiences: [...(row.audiences ?? [])], spaces: [...row.spaces], categories: [...row.categories], brands: [...row.brands],