diff --git a/furniture-3d-system/src/app/globals.css b/furniture-3d-system/src/app/globals.css index 0e8078ec..00ce26c6 100644 --- a/furniture-3d-system/src/app/globals.css +++ b/furniture-3d-system/src/app/globals.css @@ -1189,6 +1189,11 @@ background: rgba(45, 51, 56, 0.48); } + .non-integrated-workspace-table-scroll { + overflow: auto !important; + padding-bottom: 0; + } + [data-theme='dark'] ::-webkit-scrollbar-thumb { background: rgba(220, 227, 233, 0.15); } diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 7bd4ba76..6724b7eb 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -2508,7 +2508,6 @@ function getPopulatedPlanningLayoutRowCountForList( row.style, row.styleTone, row.audience, - row.model, row.priceBand, row.brand ?? "", row.shop, 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 b42b3ab0..5fe6e6af 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 @@ -3828,28 +3828,34 @@ describe("NonIntegratedDetailClient", () => { ); await actor.click(screen.getByTestId("document-workspace-trigger-企划布局")); - const modelInput = screen.getByTestId( - "planning-layout-model-stock-space-node-permission-sku-1", - ); - expect(modelInput).not.toHaveAttribute("readonly"); fireEvent.change( - modelInput, - { target: { value: "现代书桌" } }, + screen.getByTestId( + "planning-layout-priceBand-stock-space-node-permission-sku-1", + ), + { target: { value: "500-600" } }, ); - await waitFor(() => { - const savedProduct = getNonIntegratedProductById( - "STOCK001-space-node-permission", - ); - expect(savedProduct?.planningLayoutRows?.[0]?.model).toBe("现代书桌"); - }); - await actor.click(screen.getByTestId("document-workspace-trigger-产品限价")); expect( screen.getByTestId( "product-limit-expectedDailyLimitPrice-stock-space-node-permission-sku-1", ), ).toHaveAttribute("readonly"); + + await actor.click(screen.getByTestId("document-workspace-trigger-企划布局")); + expect( + screen.getByTestId( + "planning-layout-priceBand-stock-space-node-permission-sku-1", + ), + ).toHaveValue("500-600"); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "STOCK001-space-node-permission", + ); + expect(savedProduct?.planningLayoutRows?.[0]?.priceBand).toBe("500-600"); + }); + expect( screen.queryByTestId("document-workspace-trigger-产品定厂"), ).not.toBeInTheDocument(); @@ -5692,10 +5698,6 @@ describe("NonIntegratedDetailClient", () => { { target: { value: "独居青年" } }, ); expect(within(planningWorkspace).getByText("独居青年")).toBeInTheDocument(); - fireEvent.change( - screen.getByTestId("planning-layout-model-sku-planning-layout-fields-1"), - { target: { value: "窄边书桌" } }, - ); fireEvent.change( screen.getByTestId("planning-layout-priceBand-sku-planning-layout-fields-1"), { target: { value: "500-600档" } }, @@ -5775,7 +5777,7 @@ describe("NonIntegratedDetailClient", () => { audiences: ["有孩家庭", "独居青年"], spaces: ["客厅", "书房"], categories: ["书柜/架", "书桌/电脑桌"], - model: "窄边书桌", + model: "", priceBand: "500-600档", brand: "代理品牌-顾家", brands: ["代理品牌-顾家"], @@ -5785,6 +5787,166 @@ describe("NonIntegratedDetailClient", () => { }); }); + it("copies planning layout field values and replaces target values on paste", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-planning-layout-copy-paste", + skuItems: [ + createSkuItem({ + id: "sku-planning-layout-copy-paste-1", + model: "SZ0230K01", + skuCode: "SZ023001", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75 胡桃色", + color: "胡桃色", + }), + createSkuItem({ + id: "sku-planning-layout-copy-paste-2", + model: "SZ0230K02", + skuCode: "SZ023002", + barcode: "SZ023002N1280V01", + specName: "款式二 140/60/75 白色", + color: "白色", + }), + ], + planningLayoutRows: [ + { + skuId: "sku-planning-layout-copy-paste-1", + style: "治愈风", + styles: ["治愈风", "日系风"], + styleTone: "", + styleTones: [], + audience: "", + audiences: [], + spaces: [], + categories: [], + model: "", + priceBand: "", + brand: "", + brands: [], + shop: "", + }, + { + skuId: "sku-planning-layout-copy-paste-2", + style: "工业风", + styles: ["工业风"], + styleTone: "", + styleTones: [], + audience: "", + audiences: [], + spaces: [], + categories: [], + model: "", + priceBand: "", + brand: "", + brands: [], + shop: "", + }, + ], + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-企划布局")); + await actor.click( + screen.getByTestId( + "planning-layout-copy-styles-sku-planning-layout-copy-paste-1", + ), + ); + fireEvent.paste( + screen.getByTestId( + "planning-layout-styles-draft-sku-planning-layout-copy-paste-2", + ), + ); + + const planningWorkspace = screen.getByTestId("planning-layout-workspace"); + expect(within(planningWorkspace).queryByText("工业风")).not.toBeInTheDocument(); + expect(within(planningWorkspace).getAllByText("治愈风")).toHaveLength(2); + expect(within(planningWorkspace).getAllByText("日系风")).toHaveLength(2); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "DNZ0001-planning-layout-copy-paste", + ); + expect(savedProduct?.planningLayoutRows).toEqual([ + expect.objectContaining({ + skuId: "sku-planning-layout-copy-paste-1", + style: "治愈风", + styles: ["治愈风", "日系风"], + }), + expect.objectContaining({ + skuId: "sku-planning-layout-copy-paste-2", + style: "治愈风", + styles: ["治愈风", "日系风"], + }), + ]); + }); + }); + + it("keeps planning layout price band empty after manual clear", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-planning-layout-price-clear", + skuItems: [ + createSkuItem({ + id: "sku-planning-layout-price-clear-1", + model: "SZ0230K01", + skuCode: "SZ023001", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75 胡桃色", + }), + ], + productLimitRows: [ + { + skuId: "sku-planning-layout-price-clear-1", + expectedDailyLimitPrice: "599", + expectedLimitGrossMarginRate: "", + nonSpuOwnerLimitPrice: "", + }, + ], + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-企划布局")); + const priceBandInput = screen.getByTestId( + "planning-layout-priceBand-sku-planning-layout-price-clear-1", + ); + expect(priceBandInput).toHaveValue("500-600"); + + fireEvent.change(priceBandInput, { target: { value: "" } }); + + expect(priceBandInput).toHaveValue(""); + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "DNZ0001-planning-layout-price-clear", + ); + expect(savedProduct?.planningLayoutRows).toEqual([ + expect.objectContaining({ + skuId: "sku-planning-layout-price-clear-1", + priceBand: "", + priceBandAutoCleared: true, + }), + ]); + }); + }); + it("renders product limit rows and persists editable limit fields", async () => { const actor = userEvent.setup(); 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 6964b5db..6989a20f 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -23,7 +23,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { ArrowLeft, Ban, ChevronDown, Plus, Search, X } from "lucide-react"; +import { ArrowLeft, Ban, ChevronDown, Copy, Plus, Search, X } from "lucide-react"; import CameraScannerModal from "@/components/mobile/CameraScannerModal"; import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel"; import Button from "@/components/ui/Button"; @@ -2698,7 +2698,6 @@ const PLANNING_LAYOUT_SPACE_CATEGORY_OPTIONS: Record = { 卫生间: ["洗脸池柜", "镜柜", "镜子", "置物架", "毛巾架", "矮凳"], 厨房: ["桌面置物架", "落地置物架", "储物柜", "矮凳", "推车", "桌面收纳架"], }; -const PLANNING_LAYOUT_MODEL_OPTIONS: string[] = []; const PLANNING_LAYOUT_SHOP_OPTIONS: string[] = []; const SINGLE_VALUE_BRAND_FIELD_KEY = "brand"; @@ -5589,14 +5588,12 @@ type PlanningLayoutSkuRow = { audiences: string[]; spaces: string[]; categories: string[]; - planningModel: string; priceBand: string; brands: string[]; shop: string; }; type PlanningLayoutTextFieldKey = - | "model" | "priceBand" | "shop"; type PlanningLayoutListFieldKey = @@ -5606,6 +5603,20 @@ type PlanningLayoutListFieldKey = | "spaces" | "categories" | "brands"; +type PlanningLayoutCopyFieldKey = + | PlanningLayoutTextFieldKey + | PlanningLayoutListFieldKey; +type PlanningLayoutClipboardValue = + | { + field: PlanningLayoutTextFieldKey; + type: "text"; + value: string; + } + | { + field: PlanningLayoutListFieldKey; + type: "list"; + values: string[]; + }; type ProductLimitDisplayRow = NonIntegratedProductLimitRow & { image: string; @@ -5716,22 +5727,23 @@ function createEmptyPlanningLayoutRow( skuId: string, defaultBrands: string[] = [], ): NonIntegratedPlanningLayoutRow { - return { - skuId, - style: "", - styles: [], - styleTone: "", + return { + skuId, + style: "", + styles: [], + styleTone: "", styleTones: [], audience: "", audiences: [], spaces: [], - categories: [], - model: "", - priceBand: "", - brand: defaultBrands[0] ?? "", - brands: defaultBrands, - shop: "", - }; + categories: [], + model: "", + priceBand: "", + priceBandAutoCleared: false, + brand: defaultBrands[0] ?? "", + brands: defaultBrands, + shop: "", + }; } function getPlanningLayoutStyleToneOptions( @@ -5805,8 +5817,11 @@ function normalizePlanningLayoutRowsForPayload( audiences, spaces: getUniqueNonEmptyStrings(row.spaces), categories: getUniqueNonEmptyStrings(row.categories), - model: row.model.trim(), + model: "", priceBand: row.priceBand.trim(), + ...(row.priceBandAutoCleared === true && !row.priceBand.trim() + ? { priceBandAutoCleared: true } + : {}), brand: brands[0] ?? "", brands, shop: row.shop.trim(), @@ -5820,10 +5835,10 @@ function normalizePlanningLayoutRowsForPayload( row.style, row.styleTone, row.audience, - row.model, row.priceBand, row.brand ?? "", row.shop, + row.priceBandAutoCleared ? "1" : "", ...row.styles, ...row.styleTones, ...row.audiences, @@ -5896,8 +5911,9 @@ function buildPlanningLayoutSkuRows( ]), spaces: row?.spaces ?? [], categories: row?.categories ?? [], - planningModel: row?.model.trim() ?? "", - priceBand: row?.priceBand.trim() || autoPriceBand, + priceBand: + row?.priceBand.trim() || + (row?.priceBandAutoCleared ? "" : autoPriceBand), brands: savedBrands, shop: row?.shop.trim() ?? "", }; @@ -6794,6 +6810,7 @@ function InlineSuggestionInput({ showChevron = true, filterOptions = false, closeOnChange, + onPaste, onChange, onSelect, onCommit, @@ -6811,6 +6828,7 @@ function InlineSuggestionInput({ showChevron?: boolean; filterOptions?: boolean; closeOnChange?: (value: string) => boolean; + onPaste?: (event: ClipboardEvent) => void; onChange: (value: string) => void; onSelect?: (value: string) => void; onCommit?: () => void; @@ -6885,6 +6903,7 @@ function InlineSuggestionInput({ setIsOpen(closeOnChange?.(nextValue) ? false : true); } }} + onPaste={onPaste} onFocus={() => { if (hasOptions) setIsOpen(true); }} @@ -10243,6 +10262,8 @@ export default function NonIntegratedDetailClient({ const [planningLayoutDraftInputs, setPlanningLayoutDraftInputs] = useState< Record >({}); + const [planningLayoutClipboard, setPlanningLayoutClipboard] = + useState(null); const [planningLayoutBrandPickerSkuId, setPlanningLayoutBrandPickerSkuId] = useState(null); const [ @@ -14303,6 +14324,10 @@ export default function NonIntegratedDetailClient({ useEffect(() => { return () => { + if (autoSaveTimeoutRef.current) { + window.clearTimeout(autoSaveTimeoutRef.current); + autoSaveTimeoutRef.current = null; + } if (autoSaveNoticeTimeoutRef.current) { window.clearTimeout(autoSaveNoticeTimeoutRef.current); autoSaveNoticeTimeoutRef.current = null; @@ -14601,13 +14626,6 @@ export default function NonIntegratedDetailClient({ } scheduleAutoSave(); - - return () => { - if (autoSaveTimeoutRef.current) { - window.clearTimeout(autoSaveTimeoutRef.current); - autoSaveTimeoutRef.current = null; - } - }; }, [ bomDetails, documentWorkspaceNotes, @@ -16455,6 +16473,20 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + function updatePlanningLayoutTextField( + skuId: string, + field: PlanningLayoutTextFieldKey, + value: string, + ) { + updatePlanningLayoutRow(skuId, (current) => ({ + ...current, + [field]: value, + ...(field === "priceBand" + ? { priceBandAutoCleared: value.trim().length === 0 } + : {}), + })); + } + function getPlanningLayoutDraftKey( skuId: string, field: PlanningLayoutListFieldKey, @@ -16588,6 +16620,96 @@ export default function NonIntegratedDetailClient({ }); } + function getPlanningLayoutFieldTextValue( + sourceRow: PlanningLayoutSkuRow, + field: PlanningLayoutCopyFieldKey, + ) { + if (field === "priceBand" || field === "shop") return sourceRow[field].trim(); + + return getUniqueNonEmptyStrings(sourceRow[field]).join("、"); + } + + async function copyPlanningLayoutFieldToClipboard( + sourceRow: PlanningLayoutSkuRow, + field: PlanningLayoutCopyFieldKey, + label: string, + ) { + if (isReadOnly) return; + + const textValue = getPlanningLayoutFieldTextValue(sourceRow, field); + + if (field === "priceBand" || field === "shop") { + setPlanningLayoutClipboard({ + field, + type: "text", + value: textValue, + }); + } else { + setPlanningLayoutClipboard({ + field, + type: "list", + values: getUniqueNonEmptyStrings(sourceRow[field]), + }); + } + + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(textValue); + } catch { + // 页面内剪贴板已记录,系统剪贴板失败不影响本页 Ctrl+V。 + } + } + + setSaveMessage(`已复制${label},请选择目标字段后按 Ctrl+V`); + setSaveMessageType("success"); + } + + function pastePlanningLayoutClipboardToField( + skuId: string, + field: PlanningLayoutCopyFieldKey, + ) { + if (isReadOnly || !planningLayoutClipboard) return false; + if (planningLayoutClipboard.field !== field) { + setSaveMessage("复制内容和当前字段不一致,请复制同一列后再粘贴"); + setSaveMessageType("error"); + return true; + } + + if (planningLayoutClipboard.type === "text") { + updatePlanningLayoutRow(skuId, (current) => ({ + ...current, + [field]: planningLayoutClipboard.value, + ...(field === "priceBand" + ? { + priceBandAutoCleared: + planningLayoutClipboard.value.trim().length === 0, + } + : {}), + })); + return true; + } + + if ( + field !== "styles" && + field !== "styleTones" && + field !== "audiences" && + field !== "spaces" && + field !== "categories" && + field !== "brands" + ) { + return false; + } + + updatePlanningLayoutRow(skuId, (current) => + applyPlanningLayoutListValues( + current, + field, + planningLayoutClipboard.values, + ), + ); + return true; + } + function updateSampleMeasurementRow( skuId: string, key: Exclude, @@ -21992,7 +22114,7 @@ export default function NonIntegratedDetailClient({ )} -
+
{renderFactoryQualityWorkspace()}
@@ -22271,7 +22393,7 @@ export default function NonIntegratedDetailClient({ -
+
{productFactoryRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再定厂。 @@ -22281,7 +22403,7 @@ export default function NonIntegratedDetailClient({ 当前批次还没有 SKU 信息。
) : activeProductFactoryTab === "factoryInfo" ? ( -
+
@@ -22523,7 +22645,7 @@ export default function NonIntegratedDetailClient({
) : ( -
+
@@ -22623,7 +22745,7 @@ export default function NonIntegratedDetailClient({ -
+
{productSampleMeasurementRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再进行样包测量。 @@ -22633,8 +22755,8 @@ export default function NonIntegratedDetailClient({ 当前批次还没有 SKU 信息。
) : ( -
-
+
+
{[ @@ -22798,7 +22920,7 @@ export default function NonIntegratedDetailClient({ -
+
{productFinishedSampleShipmentRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再进行成品寄样。 @@ -22808,7 +22930,7 @@ export default function NonIntegratedDetailClient({ 当前批次还没有 SKU 信息。
) : ( -
+
@@ -22979,6 +23101,26 @@ export default function NonIntegratedDetailClient({ ); }; + const renderCopyFieldButton = ( + row: PlanningLayoutSkuRow, + field: PlanningLayoutCopyFieldKey, + label: string, + ) => { + if (isReadOnly || displayedPlanningLayoutSkuRows.length < 2) return null; + + return ( + + ); + }; const renderSearchCell = ({ row, field, @@ -23015,12 +23157,14 @@ export default function NonIntegratedDetailClient({ onChange(nextValue); return; } - updatePlanningLayoutRow(row.skuId, (current) => ({ - ...current, - [field]: nextValue, - })); + updatePlanningLayoutTextField(row.skuId, field, nextValue); }} placeholder={`选择${label}`} + onPaste={(event) => { + if (pastePlanningLayoutClipboardToField(row.skuId, field)) { + event.preventDefault(); + } + }} wrapperClassName="relative min-w-0 flex-1" inputClassName={`min-w-0 w-full bg-transparent pr-7 text-xs outline-none placeholder:text-apple-label-tertiary ${ isReadOnly @@ -23029,6 +23173,7 @@ export default function NonIntegratedDetailClient({ }`} panelClassName="absolute left-0 top-[calc(100%+6px)] z-50 max-h-56 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md" /> + {renderCopyFieldButton(row, field, label)} ); }; @@ -23110,6 +23255,15 @@ export default function NonIntegratedDetailClient({ closeOnChange={(nextValue) => autoAddOnChange && options.includes(nextValue.trim()) } + onPaste={(event) => { + if (pastePlanningLayoutClipboardToField(row.skuId, field)) { + event.preventDefault(); + setPlanningLayoutDraftInputs((current) => ({ + ...current, + [draftKey]: "", + })); + } + }} onChange={(nextValue) => { updatePlanningLayoutDraftInput(row.skuId, field, nextValue); if (!autoAddOnChange) return; @@ -23130,6 +23284,7 @@ export default function NonIntegratedDetailClient({ onCommit={() => addPlanningLayoutListValue(row.skuId, field)} /> )} + {renderCopyFieldButton(row, field, label)} ); @@ -23294,7 +23449,7 @@ export default function NonIntegratedDetailClient({ -
+
{planningLayoutSkuRows.length === 0 ? (
{derivedSkuItems.length === 0 @@ -23306,7 +23461,7 @@ export default function NonIntegratedDetailClient({ 当前批次还没有 SKU 信息。
) : ( -
+
@@ -23331,7 +23486,6 @@ export default function NonIntegratedDetailClient({ "适用人群", "适用空间", "关联品类", - "款式", "价格带", "适配品牌", "店铺", @@ -23475,18 +23629,6 @@ export default function NonIntegratedDetailClient({ autoAddOnChange: true, })} -
- {renderSearchCell({ - row, - field: "model", - label: "款式", - value: row.planningModel, - options: getUniqueNonEmptyStrings([ - ...PLANNING_LAYOUT_MODEL_OPTIONS, - row.planningModel, - ]), - })} - {renderSearchCell({ row, @@ -23599,7 +23741,7 @@ export default function NonIntegratedDetailClient({ -
+
{productLimitDisplayRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再填写产品限价。 @@ -23609,7 +23751,7 @@ export default function NonIntegratedDetailClient({ 当前批次还没有 SKU 信息。
) : ( -
+
@@ -23714,7 +23856,7 @@ export default function NonIntegratedDetailClient({ -
+
{productPricingRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再核价。 @@ -23724,7 +23866,7 @@ export default function NonIntegratedDetailClient({ 当前批次还没有 SKU 信息。
) : ( -
+
@@ -23994,7 +24136,7 @@ function renderFactoryQualityWorkspace() { -
+
{productFirstQualityRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再进行首批质检。 @@ -24004,8 +24146,8 @@ function renderFactoryQualityWorkspace() { 当前批次还没有 SKU 信息。
) : ( -
-
+
+
{[ 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 e03a3245..00fb9ac1 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts @@ -433,6 +433,7 @@ export type NonIntegratedPlanningLayoutRow = { categories: string[]; model: string; priceBand: string; + priceBandAutoCleared?: boolean; brand?: string; brands: string[]; shop: string; @@ -1400,6 +1401,9 @@ function normalizePlanningLayoutRow( categories: normalizeStringList(item.categories), model: normalizeString(item.model), priceBand: normalizeString(item.priceBand), + ...(item.priceBandAutoCleared === true + ? { priceBandAutoCleared: true } + : {}), brand: normalizeNonIntegratedBrand(normalizedBrands[0]) ?? "", brands: normalizeNonIntegratedBrandList(normalizedBrands), shop: normalizeString(item.shop),