fix(intake): 调整空间布局字段选项

This commit is contained in:
WuYongJie 2026-05-20 16:13:04 +08:00
parent 606dc9612c
commit a1209cc7f8
5 changed files with 274 additions and 84 deletions

View File

@ -1942,6 +1942,9 @@ function getPopulatedPlanningLayoutRowCountForList(
row.priceBand,
row.brand ?? "",
row.shop,
...(row.styles ?? []),
...(row.styleTones ?? []),
...(row.audiences ?? []),
...row.brands,
...row.spaces,
...row.categories,

View File

@ -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: "窄边书桌",

View File

@ -2375,20 +2375,8 @@ const PLANNING_LAYOUT_SPACE_CATEGORY_OPTIONS: Record<string, string[]> = {
卫生间: ["洗脸池柜", "镜柜", "镜子", "置物架", "毛巾架", "矮凳"],
厨房: ["桌面置物架", "落地置物架", "储物柜", "矮凳", "推车", "桌面收纳架"],
};
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<string>,
styleTones: ReadonlyArray<string> = [],
) {
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}
</td>
<td className="px-3 py-2">
{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,
})),
})}
</td>
<td className="px-3 py-2">
{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,
})}
</td>
<td className="px-3 py-2">
{renderSearchCell({
{renderMultiSelectCell({
row,
field: "audience",
field: "styleTones",
label: "风格细分/色系",
values: row.styleTones ?? [],
options: getPlanningLayoutStyleToneOptions(
row.styles ?? [],
row.styleTones ?? [],
),
minWidth: "180px",
autoAddOnChange: true,
})}
</td>
<td className="px-3 py-2">
{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,
})}
</td>
<td className="px-3 py-2">

View File

@ -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({

View File

@ -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>,
): 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],