From 872f7cbc1460d6ec64e996726e75147373b2aa86 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 21 May 2026 18:54:40 +0800 Subject: [PATCH 1/4] fix: refine intake 3d material and sku confirmation flow --- .../src/app/intake/IntakeClient.tsx | 2 + .../NonIntegratedDetailClient.test.tsx | 700 +++++- .../NonIntegratedDetailClient.tsx | 1968 +++++++++++++++-- .../app/intake/non-integrated/[id]/page.tsx | 7 +- .../nonIntegratedStorage.test.ts | 2 + .../non-integrated/nonIntegratedStorage.ts | 28 + .../src/components/3d/ModelViewer.tsx | 183 +- .../3d/__tests__/ModelViewer.gizmo.test.tsx | 28 +- .../3d/__tests__/ModelViewer.test.tsx | 49 +- .../platform/ViewportStageShell.tsx | 4 +- .../lib/non-integrated-review-sheet-export.ts | 29 +- .../services/jst-sku-stock-sync.service.ts | 2 + 12 files changed, 2805 insertions(+), 197 deletions(-) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index db4deca5..34483777 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -9573,6 +9573,7 @@ export default function IntakeClient(props: IntakeClientProps) { seriesNameOptions={seriesNameOptions} existingSeriesNames={existingSeriesNames} seriesDraftSkuOptions={seriesDraftSkuOptions} + materialLibraryFinishes={props.materialLibraryFinishes} initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view} initialDocumentWorkspaceCategory={activeWorkspaceTab.detailState?.category} currentUserRole={props.user.role} @@ -9649,6 +9650,7 @@ export default function IntakeClient(props: IntakeClientProps) { seriesNameOptions={seriesNameOptions} existingSeriesNames={existingSeriesNames} seriesDraftSkuOptions={seriesDraftSkuOptions} + materialLibraryFinishes={props.materialLibraryFinishes} currentUserRole={props.user.role} currentUserId={props.user.id} currentUserName={props.user.name} 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 750fff7c..13fc0f85 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 @@ -35,6 +35,7 @@ import { PRODUCT_PATENT_STORAGE_KEY, type ProductPatentRecord, } from "../productPatentStorage"; +import type { MaterialDTO } from "@/server/services"; import { SPU_RULE_LIBRARY_STORAGE_KEY } from "../spuRuleLibraryLogic"; import { parseZipFile } from "@/lib/zip-parser"; import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip"; @@ -62,8 +63,15 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({ emptyTitle?: string; emptyDescription?: string; overlayTopRight?: ReactNode; + overlayRight?: ReactNode; overlayBottomCenter?: ReactNode; onModelNodesChange?: (nodeNames: string[]) => void; + onNodeContextMenu?: (payload: { + nodeName?: string | null; + rawNodeName?: string | null; + clientX: number; + clientY: number; + }) => void; onNodePositionOffsetChange?: (payload: { nodeName: string; offset: [number, number, number]; @@ -93,6 +101,7 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({
{props.emptyTitle}
{props.emptyDescription}
{props.overlayTopRight}
+
{props.overlayRight}
{props.overlayBottomCenter}
); @@ -316,6 +325,8 @@ function createProductRecord( productImageName: "", size: "", material: "", + barcode: "", + reviewResult: "", color: "", issues: [], }, @@ -371,10 +382,46 @@ function createSkuItem( model3dDataUrl: "", model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dNodeMaterialIds: {}, ...overrides, }; } +function createMaterialFinish( + overrides: Partial & Pick, +): MaterialDTO { + return { + id: overrides.id, + name: overrides.name, + code: overrides.code ?? overrides.id.toUpperCase(), + categoryId: overrides.categoryId ?? "material-category-test", + categoryName: overrides.categoryName ?? "wood", + categoryIcon: overrides.categoryIcon ?? null, + thumbnailUrl: overrides.thumbnailUrl ?? "/materials/thumbnails/default.svg", + previewUrl: overrides.previewUrl, + textureUrl: overrides.textureUrl ?? null, + normalMapUrl: overrides.normalMapUrl ?? null, + roughnessMapUrl: overrides.roughnessMapUrl ?? null, + metalnessMapUrl: overrides.metalnessMapUrl ?? null, + aoMapUrl: overrides.aoMapUrl ?? null, + color: overrides.color ?? "#8b5a2b", + metalness: overrides.metalness ?? 0, + roughness: overrides.roughness ?? 0.65, + opacity: overrides.opacity ?? 1, + transmission: overrides.transmission, + ior: overrides.ior, + thickness: overrides.thickness, + clearcoat: overrides.clearcoat, + clearcoatRoughness: overrides.clearcoatRoughness, + envMapIntensity: overrides.envMapIntensity, + textureRepeat: overrides.textureRepeat ?? 1, + textureRotation: overrides.textureRotation ?? 0, + textureSizeMm: overrides.textureSizeMm ?? 1000, + isPreset: overrides.isPreset ?? false, + isActive: overrides.isActive ?? true, + }; +} + describe("NonIntegratedDetailClient", () => { let anchorClickSpy: jest.SpyInstance; @@ -3504,6 +3551,7 @@ describe("NonIntegratedDetailClient", () => { id: "sku-review-factory-option", manufacturer: "兰考华兰家具", manufacturerCode: "N1280", + barcode: "SZ023001N1280V01", size: "140/60/78", material: "橡胶木", color: "朴像胡桃木色", @@ -3524,10 +3572,13 @@ describe("NonIntegratedDetailClient", () => { await actor.click(screen.getByTestId("document-workspace-trigger-评审资料")); const workspace = screen.getByTestId("review-sheet-workspace"); + expect(within(workspace).queryByText("评审单基础信息")).not.toBeInTheDocument(); expect(within(workspace).getByLabelText("产品名称")).toHaveValue(""); expect(within(workspace).getByLabelText("尺寸")).toHaveValue(""); expect(within(workspace).getByLabelText("材质")).toHaveValue(""); expect(within(workspace).getByLabelText("颜色")).toHaveValue(""); + expect(within(workspace).getByLabelText("条码")).toHaveValue(""); + expect(within(workspace).getByLabelText("审样结果")).toHaveValue(""); expect( within(workspace).queryByTestId("review-sheet-product-image"), ).not.toBeInTheDocument(); @@ -3545,6 +3596,285 @@ describe("NonIntegratedDetailClient", () => { expect(within(workspace).getByLabelText("尺寸")).toHaveValue(""); expect(within(workspace).getByLabelText("材质")).toHaveValue(""); expect(within(workspace).getByLabelText("颜色")).toHaveValue(""); + + await selectInlineOption( + actor, + screen.getByLabelText("条码"), + "SZ023001N1280V01", + ); + await selectInlineOption( + actor, + within(workspace).getByLabelText("审样结果"), + "产前样", + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-review-manual-base-fields", + ); + expect(savedProduct?.reviewSheet.barcode).toBe("SZ023001N1280V01"); + expect(savedProduct?.reviewSheet.reviewResult).toBe("产前样"); + }); + }); + + it("locks the confirmed review result but keeps barcode binding editable for no-modification samples", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "SZ0230-review-no-modification-barcode-binding", + spu: "SZ0230", + skuItems: [ + createSkuItem({ + id: "sku-review-no-modification-1", + model: "SZ0230K01", + skuCode: "SZ023001", + manufacturerCode: "N1280", + barcode: "SZ023001N1280V01", + }), + createSkuItem({ + id: "sku-review-no-modification-2", + model: "SZ0230K02", + skuCode: "SZ023002", + manufacturerCode: "N1280", + barcode: "SZ023002N1280V01", + }), + ], + }), + ); + + render( + , + ); + + const workspace = screen.getByTestId("review-sheet-workspace"); + await selectInlineOption( + actor, + within(workspace).getByLabelText("审样结果"), + "无需修改", + ); + + const confirmButton = screen.getByRole("button", { name: "确认评审资料" }); + expect(confirmButton).toBeEnabled(); + await actor.click(confirmButton); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-review-no-modification-barcode-binding", + ); + expect(savedProduct?.reviewSheet.reviewResult).toBe("无需修改"); + expect(savedProduct?.reviewSheet.barcode).toBe(""); + expect(savedProduct?.documentWorkspaceConfirmations?.评审资料).toBe(true); + expect(savedProduct?.documentWorkspaceConfirmations?.成品寄样).toBe(true); + expect(savedProduct?.finishedSampleShipmentRows).toEqual([ + { + skuId: "sku-review-no-modification-1", + sampleQuantity: "0", + shipped: "是", + trackingNumber: "已有样品", + }, + { + skuId: "sku-review-no-modification-2", + sampleQuantity: "0", + shipped: "是", + trackingNumber: "已有样品", + }, + ]); + }); + + expect(within(workspace).getByLabelText("审样结果")).toBeDisabled(); + expect(within(workspace).getByLabelText("条码")).toBeEnabled(); + + await selectInlineOption( + actor, + within(workspace).getByLabelText("条码"), + "SZ023001N1280V01", + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-review-no-modification-barcode-binding", + ); + expect(savedProduct?.reviewSheet.reviewResult).toBe("无需修改"); + expect(savedProduct?.reviewSheet.barcode).toBe("SZ023001N1280V01"); + expect(savedProduct?.documentWorkspaceConfirmations?.评审资料).toBe(true); + expect(savedProduct?.documentWorkspaceConfirmations?.成品寄样).toBe(true); + expect(savedProduct?.finishedSampleShipmentRows).toEqual([ + { + skuId: "sku-review-no-modification-1", + sampleQuantity: "1", + shipped: "是", + trackingNumber: "已有样品", + }, + { + skuId: "sku-review-no-modification-2", + sampleQuantity: "0", + shipped: "是", + trackingNumber: "已有样品", + }, + ]); + }); + }); + + it("supports pasting issue and reference images into sample review cells", async () => { + const actor = userEvent.setup(); + const productId = "SZ0230-review-paste-images"; + const issueId = "review-issue-paste"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "SZ0230", + reviewSheet: { + ...createProductRecord().reviewSheet, + issues: [ + { + id: issueId, + issueImage: "", + issueImageName: "", + issueImages: [], + issueDescription: "issue row", + fixSuggestion: "", + referenceImage: "", + referenceImageName: "", + referenceImages: [], + }, + ], + }, + }), + ); + + render( + , + ); + + await actor.click( + screen.getByTestId("document-workspace-trigger-\u8bc4\u5ba1\u8d44\u6599"), + ); + + const issueImage = new File(["issue"], "issue-paste.png", { + type: "image/png", + }); + fireEvent.paste( + screen.getByTestId(`review-sheet-issue-paste-zone-${issueId}`), + { + clipboardData: { + files: [issueImage], + items: [], + }, + }, + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.reviewSheet.issues[0]?.issueImages).toEqual([ + expect.objectContaining({ + fileName: "issue-paste.png", + url: "data:image/png;base64,dGVzdA==", + isPrimary: true, + }), + ]); + }); + + const referenceImage = new File(["reference"], "reference-paste.webp", { + type: "image/webp", + }); + fireEvent.paste( + screen.getByTestId(`review-sheet-reference-paste-zone-${issueId}`), + { + clipboardData: { + files: [referenceImage], + items: [], + }, + }, + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.reviewSheet.issues[0]?.referenceImages).toEqual([ + expect.objectContaining({ + fileName: "reference-paste.webp", + url: "data:image/webp;base64,dGVzdA==", + isPrimary: true, + }), + ]); + }); + }); + + it("allows deleting the last sample review reference image", async () => { + const actor = userEvent.setup(); + const productId = "SZ0230-review-delete-last-image"; + const issueId = "review-issue-delete-last-image"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "SZ0230", + reviewSheet: { + ...createProductRecord().reviewSheet, + issues: [ + { + id: issueId, + issueImage: "", + issueImageName: "", + issueImages: [], + issueDescription: "keep row visible", + fixSuggestion: "", + referenceImage: "data:image/png;base64,old", + referenceImageName: "old-reference.png", + referenceImages: [ + { + id: "reference-image-delete-last", + url: "data:image/png;base64,old", + fileName: "old-reference.png", + isPrimary: true, + }, + ], + }, + ], + }, + }), + ); + + render( + , + ); + + await actor.click( + screen.getByTestId("document-workspace-trigger-\u8bc4\u5ba1\u8d44\u6599"), + ); + await actor.click( + screen.getByTestId(`review-sheet-reference-image-manager-${issueId}`), + ); + + const manager = screen.getByTestId( + `review-sheet-reference-image-manager-paste-zone-${issueId}`, + ); + await actor.click(within(manager).getByRole("button", { name: "删除" })); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + const issue = savedProduct?.reviewSheet.issues[0]; + expect(issue?.referenceImages).toEqual([]); + expect(issue?.referenceImage).toBe(""); + expect(issue?.referenceImageName).toBe(""); + }); }); it("renders sample measurement rows from SKU data and persists purchaser fields", async () => { @@ -4297,27 +4627,21 @@ describe("NonIntegratedDetailClient", () => { }); expect(screen.getByRole("button", { name: "确认推送" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "批量确认推送" })).toBeDisabled(); expect(screen.queryByText("已录入")).not.toBeInTheDocument(); expect(screen.queryByText("已推送")).not.toBeInTheDocument(); const confirmPricingButton = screen.getByRole("button", { name: "确认核价", }); - expect(confirmPricingButton).toBeEnabled(); + expect(confirmPricingButton).toBeDisabled(); - await actor.click(confirmPricingButton); - - await waitFor(() => { - const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); - expect(savedProduct?.documentWorkspaceConfirmations?.产品核价).toBe(true); - expect(savedProduct?.documentWorkspaceApprovedAt?.产品核价).toEqual( - expect.any(String), - ); - }); - - expect(screen.getByPlaceholderText("0")).toHaveAttribute("readonly"); + const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); + expect(savedProduct?.documentWorkspaceConfirmations?.产品核价).not.toBe(true); + expect(savedProduct?.documentWorkspaceApprovedAt?.产品核价).toBeUndefined(); + expect(screen.getByPlaceholderText("0")).not.toHaveAttribute("readonly"); expect(screen.queryByPlaceholderText("请输入商品标签")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "减少成本价" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "减少成本价" })).toBeEnabled(); }); it("lets only admins reopen confirmed product factory rows before sample measurement is approved", async () => { @@ -5285,10 +5609,14 @@ describe("NonIntegratedDetailClient", () => { />, ); + expect(screen.getByTestId("document-workspace-trigger-评审资料")).toHaveAttribute( + "data-has-files", + "false", + ); expect( - within(screen.getByTestId("document-workspace-trigger-评审资料")).getByText( - "0", - ), + within( + screen.getByTestId("document-workspace-trigger-评审资料"), + ).getByLabelText("未填"), ).toBeInTheDocument(); emptyRender.unmount(); @@ -5332,10 +5660,14 @@ describe("NonIntegratedDetailClient", () => { />, ); + expect(screen.getByTestId("document-workspace-trigger-评审资料")).toHaveAttribute( + "data-has-files", + "true", + ); expect( - within(screen.getByTestId("document-workspace-trigger-评审资料")).getByText( - "1", - ), + within( + screen.getByTestId("document-workspace-trigger-评审资料"), + ).getByLabelText("已填但待确认"), ).toBeInTheDocument(); expect(screen.getByTestId("document-workspace-trigger-评审资料")).toHaveAttribute( "data-pending-confirmation", @@ -5389,7 +5721,7 @@ describe("NonIntegratedDetailClient", () => { expect( within( screen.getByTestId("document-workspace-trigger-评审资料"), - ).getByText("1"), + ).getByLabelText("已填但待确认"), ).toBeInTheDocument(); confirmSpy.mockReturnValue(true); @@ -5399,7 +5731,7 @@ describe("NonIntegratedDetailClient", () => { expect( within( screen.getByTestId("document-workspace-trigger-评审资料"), - ).getByText("2"), + ).getByLabelText("已填但待确认"), ).toBeInTheDocument(); } finally { confirmSpy.mockRestore(); @@ -5808,6 +6140,326 @@ describe("NonIntegratedDetailClient", () => { ); }); + it("applies SKU material to the whole model and supports node material overrides", async () => { + const actor = userEvent.setup(); + const walnutFinish = createMaterialFinish({ + id: "finish-walnut", + name: "walnut oak", + code: "WALNUT-OAK", + categoryName: "wood veneer", + color: "#8b5a2b", + }); + const blackFinish = createMaterialFinish({ + id: "finish-black", + name: "black metal", + code: "BLACK-METAL", + categoryName: "metal", + color: "#111111", + metalness: 0.8, + roughness: 0.35, + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-3d-material-assignment", + skuItems: [ + createSkuItem({ + id: "sku-3d-material-assignment", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + color: "walnut", + material: "solid wood", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }), + ]), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + const getMaterialAssignments = () => + getProductPreviewProps().materialAssignments as + | Array<{ nodeName: string; material: { id: string } }> + | undefined; + + await waitFor(() => + expect(getProductPreviewProps().modelUrl).toBeTruthy(), + ); + expect(screen.queryByTestId("model-material-panel")).not.toBeInTheDocument(); + + act(() => { + ( + getProductPreviewProps().onModelNodesChange as ( + nodeNames: string[], + ) => void + )(["table-top", "leg"]); + }); + act(() => { + ( + getProductPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "table-top", + clientX: 180, + clientY: 180, + }); + }); + + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ id: "finish-walnut" }), + }), + expect.objectContaining({ + nodeName: "leg", + material: expect.objectContaining({ id: "finish-walnut" }), + }), + ]), + ); + expect(screen.getByTestId("model-auto-material-label")).toHaveTextContent( + "walnut oak", + ); + + act(() => { + ( + getProductPreviewProps().onNodeSelect as (payload: { + nodeName: string; + }) => void + )({ nodeName: "leg" }); + }); + + await waitFor(() => + expect(screen.getByTestId("model-node-material-select")).not.toBeDisabled(), + ); + await selectInlineOption( + actor, + screen.getByTestId("model-node-material-select"), + "finish-black", + ); + + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ id: "finish-walnut" }), + }), + expect.objectContaining({ + nodeName: "leg", + material: expect.objectContaining({ id: "finish-black" }), + }), + ]), + ); + await waitFor(() => + expect( + getNonIntegratedProductById( + "DNZ0001-3d-material-assignment", + )?.skuItems[0]?.model3dNodeMaterialIds, + ).toEqual({ leg: "finish-black" }), + ); + }); + + it("opens model render parameters from node context menu and applies UV settings", async () => { + const actor = userEvent.setup(); + const walnutFinish = createMaterialFinish({ + id: "finish-uv-walnut", + name: "walnut oak", + code: "WALNUT-OAK", + categoryName: "wood veneer", + color: "#8b5a2b", + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-3d-uv-settings", + skuItems: [ + createSkuItem({ + id: "sku-3d-uv-settings", + model: "SZ0230K01", + barcode: "SZ023001N1280V01", + color: "walnut", + material: "solid wood", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }), + ]), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + const getMaterialAssignments = () => + getProductPreviewProps().materialAssignments as + | Array<{ + nodeName: string; + material: { id: string; textureRepeatU?: number }; + }> + | undefined; + + await waitFor(() => + expect(getProductPreviewProps().modelUrl).toBeTruthy(), + ); + + act(() => { + ( + getProductPreviewProps().onModelNodesChange as ( + nodeNames: string[], + ) => void + )(["table-top"]); + }); + act(() => { + ( + getProductPreviewProps().onNodeContextMenu as (payload: { + nodeName: string; + clientX: number; + clientY: number; + }) => void + )({ + nodeName: "table-top", + clientX: 180, + clientY: 180, + }); + }); + + await actor.click(await screen.findByText("渲染参数")); + expect( + await screen.findByTestId("model-uv-settings-panel"), + ).toBeInTheDocument(); + fireEvent.change(screen.getByTestId("model-uv-textureRepeatU"), { + target: { value: "2" }, + }); + + await waitFor(() => + expect( + getNonIntegratedProductById("DNZ0001-3d-uv-settings")?.skuItems[0] + ?.model3dUvConfig, + ).toEqual({ + version: 1, + nodes: { + "table-top": { + textureRepeatU: 2, + }, + }, + }), + ); + await waitFor(() => + expect(getMaterialAssignments()).toEqual([ + expect.objectContaining({ + nodeName: "table-top", + material: expect.objectContaining({ + id: "finish-uv-walnut", + textureRepeatU: 2, + }), + }), + ]), + ); + }); + + it("shows model material controls only to assigned designers or admins", async () => { + const materialFinish = createMaterialFinish({ + id: "finish-designer-only", + name: "walnut oak", + }); + const product = createProductRecord({ + id: "DNZ0001-3d-material-permission", + designer: "Alice", + openingReference: { + ...createEmptyNonIntegratedOpeningReference(), + designer: "Alice", + structureDesigner: "Bob", + }, + skuItems: [ + createSkuItem({ + id: "sku-3d-material-permission", + model3dFileName: "model.glb", + model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=", + }), + ], + }); + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([product]), + ); + + const deniedRender = render( + , + ); + await waitFor(() => + expect(modelPreviewPanelMock).toHaveBeenCalled(), + ); + const getLatestProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + expect(getLatestProductPreviewProps().onNodeContextMenu).toBeUndefined(); + deniedRender.unmount(); + + render( + , + ); + await waitFor(() => + expect(getLatestProductPreviewProps().onNodeContextMenu).toEqual( + expect.any(Function), + ), + ); + }); + it("renders packaging style tabs from SKU models", async () => { const actor = userEvent.setup(); const createStyleDetail = (styleKey: string) => ({ @@ -5857,6 +6509,12 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByTestId("packaging-workspace")).toHaveTextContent( "当前款式:SZ0230K01", ); + expect( + screen.getByTestId("packaging-workspace-model-upload-button"), + ).toHaveTextContent("上传模型"); + expect( + screen.getByTestId("packaging-workspace-model-empty-upload-button"), + ).toHaveTextContent("上传模型"); }); it("keeps the nine-view tab selectable when only detail photos exist", async () => { 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 afbbfe2a..7bdf9cc9 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import JSZip from "jszip"; import type { ChangeEvent, + ClipboardEvent, ComponentProps, ComponentType, PointerEvent as ReactPointerEvent, @@ -26,7 +27,24 @@ import { ArrowLeft, Ban, ChevronDown, Plus, Search, X } from "lucide-react"; import CameraScannerModal from "@/components/mobile/CameraScannerModal"; import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel"; import Button from "@/components/ui/Button"; +import { + calculateMenuPosition, + ContextMenu, + type ContextMenuGroup, + type ContextMenuItem, +} from "@/components/ui/ContextMenu"; import type { NodePositionOffset, NodeRotationState } from "@/lib/3d/engine"; +import type { MaterialConfig } from "@/lib/3d/material-manager"; +import { toMaterialConfig } from "@/lib/material-config"; +import { + applyAssetVariantUvConfigToMaterial, + getAssetVariantUvSurfaceConfig, + normalizeAssetVariantUvConfig, + updateAssetVariantUvConfig, + type AssetVariantUvConfig, + type AssetVariantUvProjectionAxis, + type AssetVariantUvSurfaceConfig, +} from "@/lib/asset-variant-render-config"; import type { DocumentWorkspaceStructuredPreview, PresentationPreviewData, @@ -133,6 +151,7 @@ import { type NonIntegratedSkuExportRow, } from "@/lib/non-integrated-sku-export"; import { fetchSubstrates } from "@/lib/open-product/platform-api"; +import type { MaterialDTO } from "@/server/services"; import RawMaterialLibraryPanel from "@/app/intake/RawMaterialLibraryPanel"; import { SPU_RULE_LIBRARY_UPDATED_EVENT, @@ -665,6 +684,8 @@ type StyleDetailForm = { model3dDataUrl: string; model3dNodeOffsets: PreviewNodeOffsetMap; model3dNodeRotations: PreviewNodeRotationMap; + model3dNodeMaterialIds: PreviewNodeMaterialIdMap; + model3dUvConfig: AssetVariantUvConfig | null; outerPackagingType: string; innerPackagingItems: NonIntegratedPackagingInnerItem[]; packagingModel3dFileName: string; @@ -675,13 +696,29 @@ type StyleDetailForm = { type PreviewNodeOffsetMap = Record; type PreviewNodeRotationMap = Record; +type PreviewNodeMaterialIdMap = Record; type PreviewNodeTransformState = { offsets: PreviewNodeOffsetMap; rotations: PreviewNodeRotationMap; }; +type PreviewUvEditorFormState = { + textureRepeat: string; + textureRepeatU: string; + textureRepeatV: string; + textureRotation: string; + textureSizeMm: string; + textureProjectionAxis: "" | AssetVariantUvProjectionAxis; +}; const EMPTY_PREVIEW_NODE_OFFSET_MAP: PreviewNodeOffsetMap = {}; const EMPTY_PREVIEW_NODE_ROTATION_MAP: PreviewNodeRotationMap = {}; +const UV_PROJECTION_AXIS_OPTIONS = [ + { value: "", label: "自动" }, + { value: "x", label: "X 轴" }, + { value: "y", label: "Y 轴" }, + { value: "z", label: "Z 轴" }, +] satisfies Array<{ value: "" | AssetVariantUvProjectionAxis; label: string }>; + function clonePreviewNodeOffsetMap( source: PreviewNodeOffsetMap, ): PreviewNodeOffsetMap { @@ -713,6 +750,133 @@ function clonePreviewNodeTransformState( }; } +function buildSkuContentRevisionSignature({ + skuItems, + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, +}: { + skuItems: NonIntegratedSkuItem[]; + skuBatchCount: number; + skuBatchFlowNos: Record; + skuBatchChangeTypes: Record; +}) { + return JSON.stringify({ + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, + skuItems: skuItems.map((item) => ({ + id: item.id, + skuSequence: item.skuSequence, + batchNo: item.batchNo, + packageGroupId: item.packageGroupId, + packageSuffix: item.packageSuffix, + image: item.image, + imageName: item.imageName, + model: item.model, + skuCode: item.skuCode, + setCount: item.setCount, + specName: item.specName, + barcode: item.barcode, + size: item.size, + color: item.color, + material: item.material, + boardMaterialType: item.boardMaterialType ?? "", + versionNo: item.versionNo, + attributeCode: item.attributeCode, + drawingId: item.drawingId, + installMethod: item.installMethod, + executeStandard: item.executeStandard, + nationalStandardCompliant: item.nationalStandardCompliant, + manufacturer: item.manufacturer, + manufacturerCode: item.manufacturerCode, + salesStatus: item.salesStatus, + tagMaterial: item.tagMaterial, + componentDetails: item.componentDetails ?? [], + source: item.source ?? "", + sourceTable: item.sourceTable ?? "", + sourceSkuId: item.sourceSkuId ?? "", + contentLocked: item.contentLocked === true, + })), + }); +} + +function formatPreviewUvNumber(value: number | null | undefined) { + return typeof value === "number" && Number.isFinite(value) ? String(value) : ""; +} + +function formatPreviewUvRotationDegrees(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return ""; + } + + const degrees = (value * 180) / Math.PI; + return Number.isInteger(degrees) + ? String(degrees) + : String(Number(degrees.toFixed(2))); +} + +function createPreviewUvEditorFormState( + config: AssetVariantUvSurfaceConfig | null | undefined, +): PreviewUvEditorFormState { + return { + textureRepeat: formatPreviewUvNumber(config?.textureRepeat), + textureRepeatU: formatPreviewUvNumber(config?.textureRepeatU), + textureRepeatV: formatPreviewUvNumber(config?.textureRepeatV), + textureRotation: formatPreviewUvRotationDegrees(config?.textureRotation), + textureSizeMm: formatPreviewUvNumber(config?.textureSizeMm), + textureProjectionAxis: config?.textureProjectionAxis ?? "", + }; +} + +function parsePreviewUvEditorFormState( + state: PreviewUvEditorFormState, +): AssetVariantUvSurfaceConfig | null { + const parseField = (value: string) => { + const normalized = value.trim(); + if (!normalized) return undefined; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; + }; + + const textureRepeat = parseField(state.textureRepeat); + const textureRepeatU = parseField(state.textureRepeatU); + const textureRepeatV = parseField(state.textureRepeatV); + const textureRotationDegrees = parseField(state.textureRotation); + const textureRotation = + textureRotationDegrees === undefined + ? undefined + : (textureRotationDegrees * Math.PI) / 180; + const textureSizeMm = parseField(state.textureSizeMm); + const textureProjectionAxis = + state.textureProjectionAxis === "x" || + state.textureProjectionAxis === "y" || + state.textureProjectionAxis === "z" + ? state.textureProjectionAxis + : undefined; + + if ( + textureRepeat === undefined && + textureRepeatU === undefined && + textureRepeatV === undefined && + textureRotation === undefined && + textureSizeMm === undefined && + textureProjectionAxis === undefined + ) { + return null; + } + + return { + ...(textureRepeat !== undefined ? { textureRepeat } : {}), + ...(textureRepeatU !== undefined ? { textureRepeatU } : {}), + ...(textureRepeatV !== undefined ? { textureRepeatV } : {}), + ...(textureRotation !== undefined ? { textureRotation } : {}), + ...(textureSizeMm !== undefined ? { textureSizeMm } : {}), + ...(textureProjectionAxis !== undefined ? { textureProjectionAxis } : {}), + }; +} + function arePreviewNodeOffsetMapsEqual( left: PreviewNodeOffsetMap, right: PreviewNodeOffsetMap, @@ -840,6 +1004,10 @@ const OPENING_REFERENCE_DEMAND_TYPE_OPTIONS = [ "设计需求", ] as const; const REVIEW_METHOD_OPTIONS = ["现场", "远程视频"] as const; +const REVIEW_RESULT_OPTIONS = ["无需修改", "产前样"] as const; +const REVIEW_RESULT_NO_MODIFICATION = "无需修改"; +const FINISHED_SAMPLE_SHIPMENT_AUTO_SHIPPED = "是"; +const FINISHED_SAMPLE_SHIPMENT_EXISTING_SAMPLE_TRACKING = "已有样品"; const REVIEW_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "评审资料"; const PATENT_APPLICATION_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "专利申请"; @@ -918,6 +1086,73 @@ function getPlanningLayoutHundredPriceBand(value: string | undefined) { return `${lower}-${lower + 100}`; } +function normalizeMaterialMatchText(value: string | null | undefined) { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[\s_\-—()()【】\[\]{}·.,,。/\\|]+/g, ""); +} + +function normalizePersonPermissionName(value: string | null | undefined) { + return (value ?? "").trim().replace(/\s+/g, ""); +} + +function getMaterialFinishSearchText(finish: MaterialDTO) { + return normalizeMaterialMatchText( + [finish.name, finish.code, finish.categoryName].filter(Boolean).join(" "), + ); +} + +function scoreMaterialFinishMatch( + finish: MaterialDTO, + color: string, + material: string, +) { + const finishName = normalizeMaterialMatchText(finish.name); + const finishSearchText = getMaterialFinishSearchText(finish); + const colorKey = normalizeMaterialMatchText(color); + const materialTokens = material + .split(/[\/,,、\s]+/) + .map((token) => normalizeMaterialMatchText(token)) + .filter((token) => token.length >= 2); + let score = 0; + + if (colorKey) { + if (finishName === colorKey) { + score += 120; + } else if (finishName.includes(colorKey) || colorKey.includes(finishName)) { + score += 90; + } else if (finishSearchText.includes(colorKey)) { + score += 70; + } + } + + materialTokens.forEach((token) => { + if (finishSearchText.includes(token)) { + score += 12; + } + }); + + return score; +} + +function findBestMaterialFinish( + finishes: MaterialDTO[], + color: string, + material: string, +) { + const ranked = finishes + .filter((finish) => finish.isActive !== false) + .map((finish) => ({ + finish, + score: scoreMaterialFinishMatch(finish, color, material), + })) + .filter((entry) => entry.score >= 70) + .sort((left, right) => right.score - left.score); + + return ranked[0]?.finish ?? null; +} + const FACTORY_QUALITY_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = "工厂质检"; const PACKAGING_WORKSPACE_PANEL_OPTIONS: Array<{ @@ -1639,6 +1874,7 @@ const DEFAULT_DOCUMENT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = const PRODUCT_PHOTO_CATEGORY: DocumentWorkspaceCategory = "产品实拍图"; const AUTO_SAVE_DELAY_MS = 500; const STOCK_PRODUCT_LABEL = "\u5b58\u91cf\u5546\u54c1"; +const PRODUCT_PRICING_JST_PUSH_ENABLED = false; function getChangedWorkflowNodeCategories( previousSignatures: Record | null | undefined, @@ -2089,6 +2325,54 @@ function getDocumentWorkspaceShortcutCountClass(hasFiles: boolean) { : "bg-[#f8fafc] text-[#6f7c89]"; } +function getDocumentWorkspaceSidebarButtonClass(isActive: boolean) { + return `relative grid min-h-[34px] grid-cols-[minmax(0,1fr)_22px] items-center gap-2 rounded-apple-lg border px-3 py-2 text-left transition ${ + isActive + ? "border-zinc-950 bg-zinc-950 text-white hover:border-zinc-800 hover:bg-zinc-800" + : "border-transparent bg-apple-bg-primary text-apple-label-primary hover:bg-apple-bg-secondary/40" + }`; +} + +function DocumentWorkspaceSidebarStatusIcon({ + hasContent, + needsConfirmation, +}: { + hasContent: boolean; + needsConfirmation: boolean; +}) { + if (!hasContent) { + return ( + + ); + } + + if (needsConfirmation) { + return ( + + + + ); + } + + return ( + + ✓ + + ); +} + function isProductPhotoNineViewSlot( value: string | undefined | null, ): value is ProductPhotoNineViewSlot { @@ -2627,6 +2911,8 @@ const EMPTY_STYLE_DETAIL: StyleDetailForm = { model3dDataUrl: "", model3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: {}, + model3dUvConfig: null, outerPackagingType: "", innerPackagingItems: [], packagingModel3dFileName: "", @@ -2740,6 +3026,8 @@ function createEmptySkuItem(): NonIntegratedSkuItem { model3dDataUrl: "", model3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: {}, + model3dUvConfig: null, }; } @@ -3609,6 +3897,8 @@ function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { detail.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: detail.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: detail.model3dNodeMaterialIds ?? {}, + model3dUvConfig: normalizeAssetVariantUvConfig(detail.model3dUvConfig), outerPackagingType: detail.outerPackagingType ?? "", innerPackagingItems: (detail.innerPackagingItems ?? []).map((item) => ({ ...item, @@ -3635,6 +3925,8 @@ function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { model3dDataUrl: "", model3dNodeOffsets: EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: {}, + model3dUvConfig: null, outerPackagingType: "", innerPackagingItems: [], packagingModel3dFileName: "", @@ -3672,6 +3964,8 @@ function createEmptyReviewSheet(): NonIntegratedReviewSheet { productImageName: "", size: "", material: "", + barcode: "", + reviewResult: "", color: "", issues: [createEmptyReviewIssue()], }; @@ -3688,6 +3982,8 @@ function cloneReviewSheet( ...sheet, version: sheet.version || "v0.0.1", reviewMethod: sheet.reviewMethod || "现场", + barcode: sheet.barcode ?? "", + reviewResult: sheet.reviewResult ?? "", issues: sheet.issues.length ? sheet.issues.map((issue) => cloneReviewIssue(issue)) : [createEmptyReviewIssue()], @@ -3749,6 +4045,20 @@ function buildReviewIssueImageId() { return `review-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function getImageFilesFromClipboardData(clipboardData: DataTransfer) { + const filesFromClipboard = Array.from(clipboardData.files ?? []).filter( + (file) => file.type.startsWith("image/"), + ); + if (filesFromClipboard.length > 0) { + return filesFromClipboard; + } + + return Array.from(clipboardData.items ?? []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); +} + function cloneReviewIssue( issue: NonIntegratedReviewIssue, ): NonIntegratedReviewIssue { @@ -5655,6 +5965,48 @@ function isFinishedSampleShipmentRowComplete( ); } +function normalizeReviewBarcodeForMatch(value: string | null | undefined) { + return (value ?? "").trim().toUpperCase(); +} + +function hasReviewSheetDecisionContent(sheet: NonIntegratedReviewSheet) { + return [sheet.barcode, sheet.reviewResult].some((value) => value.trim()); +} + +function hasReviewRoundContent(round: NonIntegratedReviewRound) { + return ( + hasReviewSheetDecisionContent(round.reviewSheet) || + round.reviewSheet.issues.some((issue) => hasReviewIssueContent(issue)) + ); +} + +function buildNoModificationFinishedSampleShipmentRows( + skuItems: NonIntegratedSkuItem[], + reviewBarcode: string, +) { + const normalizedReviewBarcode = normalizeReviewBarcodeForMatch(reviewBarcode); + const hasMatchedSku = + !normalizedReviewBarcode || + skuItems.some( + (sku) => + normalizeReviewBarcodeForMatch(sku.barcode) === normalizedReviewBarcode, + ); + if (!hasMatchedSku) { + return null; + } + + return skuItems.map((sku) => ({ + skuId: sku.id, + sampleQuantity: + normalizedReviewBarcode && + normalizeReviewBarcodeForMatch(sku.barcode) === normalizedReviewBarcode + ? "1" + : "0", + shipped: FINISHED_SAMPLE_SHIPMENT_AUTO_SHIPPED, + trackingNumber: FINISHED_SAMPLE_SHIPMENT_EXISTING_SAMPLE_TRACKING, + })); +} + type ProductFirstQualityDisplayRow = NonIntegratedFirstQualityRow & { image: string; model: string; @@ -6972,7 +7324,7 @@ function WorkspacePreviewEmptyState({ return (

{title} @@ -9244,6 +9596,8 @@ function buildProductPayload( item.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, model3dNodeRotations: item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + model3dNodeMaterialIds: item.model3dNodeMaterialIds ?? {}, + model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig), }; return productDevelopmentKind === "series" @@ -9262,6 +9616,8 @@ function buildProductPayload( model3dDataUrl: detail.model3dDataUrl.trim(), model3dNodeOffsets: detail.model3dNodeOffsets, model3dNodeRotations: detail.model3dNodeRotations, + model3dNodeMaterialIds: detail.model3dNodeMaterialIds, + model3dUvConfig: normalizeAssetVariantUvConfig(detail.model3dUvConfig), outerPackagingType: detail.outerPackagingType.trim(), innerPackagingItems: detail.innerPackagingItems .map((item) => ({ @@ -9401,6 +9757,12 @@ function buildProductPayload( productImageName: round.reviewSheet.productImageName.trim(), size: round.reviewSheet.size.trim(), material: round.reviewSheet.material.trim(), + barcode: round.reviewSheet.barcode?.trim() ?? "", + reviewResult: REVIEW_RESULT_OPTIONS.includes( + round.reviewSheet.reviewResult as (typeof REVIEW_RESULT_OPTIONS)[number], + ) + ? round.reviewSheet.reviewResult + : "", color: round.reviewSheet.color.trim(), issues: round.reviewSheet.issues .map((issue) => { @@ -9633,6 +9995,7 @@ export default function NonIntegratedDetailClient({ seriesNameOptions = [], existingSeriesNames = [], seriesDraftSkuOptions = [], + materialLibraryFinishes = [], }: { mode: "create" | "edit" | "view"; productId?: string; @@ -9664,6 +10027,7 @@ export default function NonIntegratedDetailClient({ seriesNameOptions?: string[]; existingSeriesNames?: string[]; seriesDraftSkuOptions?: SeriesDraftSkuOption[]; + materialLibraryFinishes?: MaterialDTO[]; }) { const router = useRouter(); const previewFileInputRef = useRef(null); @@ -10492,6 +10856,25 @@ export default function NonIntegratedDetailClient({ const previewTransformBaselineRef = useRef(null); const previewTransformNodeNameRef = useRef(null); + const [previewContextMenuState, setPreviewContextMenuState] = useState<{ + visible: boolean; + x: number; + y: number; + nodeName: string | null; + }>({ + visible: false, + x: 0, + y: 0, + nodeName: null, + }); + const [activePreviewSubmenu, setActivePreviewSubmenu] = useState< + "preview-material" | "preview-uv" | null + >(null); + const [previewUvEditorForm, setPreviewUvEditorForm] = + useState(() => + createPreviewUvEditorFormState(null), + ); + const previewUvEditorFormRef = useRef(previewUvEditorForm); const [isExportingReviewSheet, setIsExportingReviewSheet] = useState(false); const [isExportingQualityInspection, setIsExportingQualityInspection] = useState(false); @@ -10665,6 +11048,31 @@ export default function NonIntegratedDetailClient({ )); const isReadOnly = mode === "view" || isFlowAbandoned || !canEditActiveWorkspace; + const currentUserDesignerNameKey = + normalizePersonPermissionName(currentUserName); + const assignedDesignerNameKeys = useMemo( + () => + new Set( + [ + formValues.designer, + openingReference.designer, + openingReference.structureDesigner, + ] + .map(normalizePersonPermissionName) + .filter(Boolean), + ), + [ + formValues.designer, + openingReference.designer, + openingReference.structureDesigner, + ], + ); + const canEditPreviewModelMaterial = + !isReadOnly && + (!hasCurrentUserContext || + isCurrentUserAdmin || + (currentUserDesignerNameKey.length > 0 && + assignedDesignerNameKeys.has(currentUserDesignerNameKey))); const canAbandonProduct = mode === "edit" && !isCurrentUserFullDetailEditor && canEditCurrentProduct; const sharedUploadProductId = @@ -11122,6 +11530,12 @@ export default function NonIntegratedDetailClient({ activeSkuBatchNo !== null && isSkuBatchFactoryConfirmed(activeSkuBatchNo); const canEditActiveSkuBatch = !isSkuContentReadOnly && !isActiveSkuBatchFactoryConfirmed; + const canShowSkuUploadActions = !isSkuContentReadOnly; + const isSkuWorkbookUploadDisabled = + isImportingSku || + activeSkuBatchNo === null || + isActiveSkuBatchFactoryConfirmed; + const isSkuImageUploadDisabled = isActiveSkuBatchFactoryConfirmed; const displayedProductFactoryRows = useMemo(() => { if (activeDownstreamSkuBatch === "all") return productFactoryRows; return productFactoryRows.filter( @@ -11293,9 +11707,15 @@ export default function NonIntegratedDetailClient({ const effectiveSelectedReviewRoundId = selectedReviewRound?.id ?? ""; const reviewSheet = selectedReviewRound?.reviewSheet ?? createEmptyReviewSheet(); + const isReviewNodeLocked = + documentWorkspaceConfirmations[REVIEW_WORKSPACE_CATEGORY] === true && + Boolean(documentWorkspaceApprovedAt[REVIEW_WORKSPACE_CATEGORY]); + const canEditReviewSheet = !isReadOnly; + const canEditReviewResult = + !isReadOnly && (!isReviewNodeLocked || !reviewSheet.reviewResult.trim()); const canDeleteSelectedReviewRound = isCurrentUserAdmin && - !isReadOnly && + canEditReviewSheet && reviewRounds.length > 1 && Boolean(effectiveSelectedReviewRoundId); const reviewFactoryOptions = useMemo( @@ -11315,6 +11735,14 @@ export default function NonIntegratedDetailClient({ supplierNameOptions, ], ); + const reviewBarcodeOptions = useMemo( + () => + getUniqueNonEmptyStrings([ + reviewSheet.barcode, + ...derivedSkuItems.map((item) => item.barcode), + ]), + [derivedSkuItems, reviewSheet.barcode], + ); const selectedStyleKey = isComponentSupplementMode ? effectiveSelectedSupplementComponentCode ? `component:${effectiveSelectedSupplementComponentCode}` @@ -11342,6 +11770,18 @@ export default function NonIntegratedDetailClient({ ? (styleDetails[selectedStyleKey]?.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP) : EMPTY_PREVIEW_NODE_ROTATION_MAP; + const selectedStyleModelNodeMaterialIds = selectedStyleKey + ? (styleDetails[selectedStyleKey]?.model3dNodeMaterialIds ?? {}) + : {}; + const selectedStyleModelUvConfig = useMemo( + () => + selectedStyleKey + ? normalizeAssetVariantUvConfig( + styleDetails[selectedStyleKey]?.model3dUvConfig, + ) + : null, + [selectedStyleKey, styleDetails], + ); const hasSkuOverrideModel = Boolean( selectedSupplementSourceSku?.model3dDataUrl?.trim(), ); @@ -11359,6 +11799,15 @@ export default function NonIntegratedDetailClient({ ? (selectedSupplementSourceSku?.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP) : selectedStyleModelNodeRotations; + const effectiveModel3dNodeMaterialIds = + selectedSupplementSourceSku?.model3dNodeMaterialIds ?? + selectedStyleModelNodeMaterialIds; + const effectiveModel3dUvConfig = useMemo( + () => + normalizeAssetVariantUvConfig(selectedSupplementSourceSku?.model3dUvConfig) ?? + selectedStyleModelUvConfig, + [selectedStyleModelUvConfig, selectedSupplementSourceSku?.model3dUvConfig], + ); const previewNodeOffsets = useMemo( () => Object.entries(previewNodeOffsetMap).map(([nodeName, offset]) => ({ @@ -11375,12 +11824,119 @@ export default function NonIntegratedDetailClient({ })), [previewNodeRotationMap], ); + const activePreviewUvNodeName = + previewContextMenuState.nodeName ?? selectedPreviewNodeName; + const activePreviewUvNodeLabel = activePreviewUvNodeName ?? "默认全部节点"; + const materialFinishById = useMemo( + () => + new Map( + materialLibraryFinishes.map((finish) => [finish.id, finish] as const), + ), + [materialLibraryFinishes], + ); + const autoPreviewMaterialFinish = useMemo( + () => + findBestMaterialFinish( + materialLibraryFinishes, + selectedSupplementColor, + selectedSupplementMaterial, + ), + [materialLibraryFinishes, selectedSupplementColor, selectedSupplementMaterial], + ); + const autoPreviewMaterialConfig = useMemo( + () => toMaterialConfig(autoPreviewMaterialFinish), + [autoPreviewMaterialFinish], + ); + const previewMaterialAssignments = useMemo< + Array<{ nodeName: string; material: MaterialConfig }> + >(() => { + if (previewNodeNames.length === 0) return []; + + return previewNodeNames + .map((nodeName) => { + const overrideFinishId = effectiveModel3dNodeMaterialIds[nodeName]; + const overrideMaterialConfig = overrideFinishId + ? toMaterialConfig(materialFinishById.get(overrideFinishId)) + : null; + const material = overrideMaterialConfig ?? autoPreviewMaterialConfig; + const materialWithUv = material + ? (applyAssetVariantUvConfigToMaterial( + material, + effectiveModel3dUvConfig, + { nodeName }, + ) ?? material) + : null; + return materialWithUv ? { nodeName, material: materialWithUv } : null; + }) + .filter( + ( + entry, + ): entry is { + nodeName: string; + material: MaterialConfig; + } => Boolean(entry), + ); + }, [ + autoPreviewMaterialConfig, + effectiveModel3dNodeMaterialIds, + effectiveModel3dUvConfig, + materialFinishById, + previewNodeNames, + ]); + const selectedPreviewNodeMaterialId = + selectedPreviewNodeName && effectiveModel3dNodeMaterialIds[selectedPreviewNodeName] + ? effectiveModel3dNodeMaterialIds[selectedPreviewNodeName] + : ""; + const selectedPreviewNodeMaterial = + selectedPreviewNodeMaterialId.length > 0 + ? materialFinishById.get(selectedPreviewNodeMaterialId) ?? null + : null; + const [previewMaterialApplyTarget, setPreviewMaterialApplyTarget] = useState< + "all" | "node" + >("all"); + const previewMaterialQuickFinishes = useMemo(() => { + const seen = new Set(); + return [ + autoPreviewMaterialFinish, + selectedPreviewNodeMaterial, + ...materialLibraryFinishes, + ] + .filter((finish): finish is MaterialDTO => { + if (!finish || seen.has(finish.id)) return false; + seen.add(finish.id); + return true; + }) + .slice(0, 8); + }, [ + autoPreviewMaterialFinish, + materialLibraryFinishes, + selectedPreviewNodeMaterial, + ]); + useEffect(() => { + if (selectedPreviewNodeName) { + setPreviewMaterialApplyTarget("node"); + return; + } + setPreviewMaterialApplyTarget("all"); + }, [selectedPreviewNodeName]); useEffect(() => { previewNodeOffsetMapRef.current = previewNodeOffsetMap; }, [previewNodeOffsetMap]); useEffect(() => { previewNodeRotationMapRef.current = previewNodeRotationMap; }, [previewNodeRotationMap]); + useEffect(() => { + previewUvEditorFormRef.current = previewUvEditorForm; + }, [previewUvEditorForm]); + useEffect(() => { + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(effectiveModel3dUvConfig, { + nodeName: activePreviewUvNodeName, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, [activePreviewUvNodeName, effectiveModel3dUvConfig]); const selectedBomStyleKey = selectedStyleKey; const selectedBomSkuKey = !isComponentSupplementMode && selectedBomStyleKey && selectedSupplementSku @@ -11556,9 +12112,7 @@ export default function NonIntegratedDetailClient({ if (reviewRounds.length === 0) return 0; if (reviewRounds.length > 1) return reviewRounds.length; - return reviewRounds[0].reviewSheet.issues.some((issue) => - hasReviewIssueContent(issue), - ) + return hasReviewRoundContent(reviewRounds[0]) ? 1 : 0; }, @@ -11679,6 +12233,17 @@ export default function NonIntegratedDetailClient({ const displayedProductFactoryRowsComplete = displayedProductFactoryRows.length > 0 && displayedProductFactoryRows.every(isProductFactoryRowComplete); + function isProductPricingRowSystemEntered( + row: Pick, + ) { + return ( + PRODUCT_PRICING_JST_PUSH_ENABLED && + (row.systemEnteredAt ?? "").trim().length > 0 + ); + } + const allProductPricingRowsComplete = + productPricingRows.length > 0 && + productPricingRows.every(isProductPricingRowComplete); const displayedSampleMeasurementRowsComplete = displayedProductSampleMeasurementRows.length > 0 && displayedProductSampleMeasurementRows.every( @@ -11692,6 +12257,18 @@ export default function NonIntegratedDetailClient({ const displayedProductPricingRowsComplete = displayedProductPricingRows.length > 0 && displayedProductPricingRows.every(isProductPricingRowComplete); + const allProductPricingRowsSystemEntered = + productPricingRows.length > 0 && + productPricingRows.every(isProductPricingRowSystemEntered); + const productPricingCanBeConfirmed = + allProductPricingRowsComplete && allProductPricingRowsSystemEntered; + const displayedProductPricingRowsReadyForPush = + displayedProductPricingRows.length > 0 && + displayedProductPricingRows.some( + (row) => + isProductPricingRowComplete(row) && + !isProductPricingRowSystemEntered(row), + ); const displayedProductLimitRowsComplete = displayedProductLimitRows.length > 0 && displayedProductLimitRows.every(isProductLimitRowComplete); @@ -11863,9 +12440,13 @@ export default function NonIntegratedDetailClient({ (file) => resolveDetectionWorkspaceAssetType(file) === "quality-standard", ), }); - signatures[NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY] = JSON.stringify({ - skuItems, - }); + signatures[NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY] = + buildSkuContentRevisionSignature({ + skuItems, + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, + }); return signatures; }, [ @@ -11884,6 +12465,9 @@ export default function NonIntegratedDetailClient({ pricingRows, reviewRounds, sampleMeasurementRows, + skuBatchChangeTypes, + skuBatchCount, + skuBatchFlowNos, skuItems, styleDetails, uploadFiles, @@ -11901,12 +12485,22 @@ export default function NonIntegratedDetailClient({ const isWorkflowNodeConfirmed = ( category: WorkflowNodeCategory, hasContent: boolean, - ) => - !shouldRequireWorkflowConfirmation(category) || - (hasContent && + ) => { + if ( + category === PRODUCT_PRICING_WORKSPACE_CATEGORY && + !productPricingCanBeConfirmed + ) { + return false; + } + + return ( + !shouldRequireWorkflowConfirmation(category) || + (hasContent && documentWorkspaceConfirmations[category] === true && Boolean(documentWorkspaceApprovedAt[category]) && - !dirtyWorkflowNodeCategories.has(category)); + !dirtyWorkflowNodeCategories.has(category)) + ); + }; const isDocumentWorkspaceNodeLocked = (category: DocumentWorkspaceCategory) => documentWorkspaceConfirmations[category] === true && Boolean(documentWorkspaceApprovedAt[category]); @@ -11918,7 +12512,7 @@ export default function NonIntegratedDetailClient({ ); const isProductPricingNodeLocked = isDocumentWorkspaceNodeLocked( PRODUCT_PRICING_WORKSPACE_CATEGORY, - ); + ) && productPricingCanBeConfirmed; const displayedSampleMeasurementIsConfirmed = isWorkflowNodeConfirmed( SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, displayedSampleMeasurementRowsComplete, @@ -11932,7 +12526,7 @@ export default function NonIntegratedDetailClient({ ) || isFinishedSampleShipmentNodeLocked; const displayedProductPricingIsConfirmed = isWorkflowNodeConfirmed( PRODUCT_PRICING_WORKSPACE_CATEGORY, - displayedProductPricingRowsComplete, + productPricingCanBeConfirmed, ) || isProductPricingNodeLocked; const displayedProductLimitIsConfirmed = isWorkflowNodeConfirmed( PRODUCT_LIMIT_WORKSPACE_CATEGORY, @@ -11949,6 +12543,35 @@ export default function NonIntegratedDetailClient({ const basicInfoDisplayCount = basicInfoIsApproved ? 1 : 0; const basicInfoNeedsConfirmation = !basicInfoIsApproved; const isWorkflowCompleted = false; + const productPricingStoredConfirmation = + documentWorkspaceConfirmations[PRODUCT_PRICING_WORKSPACE_CATEGORY] === true; + const productPricingStoredApprovedAt = Boolean( + documentWorkspaceApprovedAt[PRODUCT_PRICING_WORKSPACE_CATEGORY], + ); + useEffect(() => { + if (productPricingCanBeConfirmed) return; + if (!productPricingStoredConfirmation && !productPricingStoredApprovedAt) { + return; + } + + setDocumentWorkspaceConfirmations((current) => { + if (!current[PRODUCT_PRICING_WORKSPACE_CATEGORY]) return current; + return { + ...current, + [PRODUCT_PRICING_WORKSPACE_CATEGORY]: false, + }; + }); + setDocumentWorkspaceApprovedAt((current) => { + if (!current[PRODUCT_PRICING_WORKSPACE_CATEGORY]) return current; + const next = { ...current }; + delete next[PRODUCT_PRICING_WORKSPACE_CATEGORY]; + return next; + }); + }, [ + productPricingCanBeConfirmed, + productPricingStoredApprovedAt, + productPricingStoredConfirmation, + ]); useEffect(() => { if (isWorkflowCompleted) return; @@ -13114,6 +13737,7 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations?: NonIntegratedDocumentWorkspaceConfirmationMap; documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap; reviewRounds?: NonIntegratedReviewRound[]; + finishedSampleShipmentRows?: NonIntegratedFinishedSampleShipmentRow[]; openingReference?: NonIntegratedOpeningReference; skuItems?: NonIntegratedSkuItem[]; skuBatchCount?: number; @@ -13128,6 +13752,8 @@ export default function NonIntegratedDetailClient({ const nextDocumentWorkspaceApprovedAt = overrides?.documentWorkspaceApprovedAt ?? documentWorkspaceApprovedAt; const nextReviewRounds = overrides?.reviewRounds ?? reviewRounds; + const nextFinishedSampleShipmentRows = + overrides?.finishedSampleShipmentRows ?? productFinishedSampleShipmentRows; const nextOpeningReference = overrides?.openingReference ?? openingReferenceRef.current; const nextSkuItems = overrides?.skuItems ?? skuItems; @@ -13166,7 +13792,7 @@ export default function NonIntegratedDetailClient({ planningLayoutRows, productLimitRows, productSampleMeasurementRows, - productFinishedSampleShipmentRows, + nextFinishedSampleShipmentRows, productFirstQualityRows, uploadFiles, documentWorkspaceNotes, @@ -14070,6 +14696,380 @@ export default function NonIntegratedDetailClient({ [hasSkuOverrideModel, selectedStyleKey, selectedSupplementSourceSku], ); + const persistPreviewNodeMaterialIds = useCallback( + (nextMaterialIds: PreviewNodeMaterialIdMap) => { + if (selectedSupplementSourceSku) { + setSkuItems((current) => + current.map((item) => + item.id === selectedSupplementSourceSku.id + ? { + ...item, + model3dNodeMaterialIds: nextMaterialIds, + } + : item, + ), + ); + return; + } + + if (selectedStyleKey) { + setStyleDetails((current) => ({ + ...current, + [selectedStyleKey]: { + ...(current[selectedStyleKey] ?? EMPTY_STYLE_DETAIL), + model3dNodeMaterialIds: nextMaterialIds, + }, + })); + } + }, + [selectedStyleKey, selectedSupplementSourceSku], + ); + + const handlePreviewNodeMaterialChange = useCallback( + (nodeName: string, materialId: string) => { + if (isReadOnly) return; + if (!nodeName) return; + + const nextMaterialIds = { ...effectiveModel3dNodeMaterialIds }; + if (materialId) { + nextMaterialIds[nodeName] = materialId; + } else { + delete nextMaterialIds[nodeName]; + } + persistPreviewNodeMaterialIds(nextMaterialIds); + }, + [effectiveModel3dNodeMaterialIds, isReadOnly, persistPreviewNodeMaterialIds], + ); + + const handlePreviewMaterialApply = useCallback( + (materialId: string) => { + if (isReadOnly) return; + + if (previewMaterialApplyTarget === "node" && selectedPreviewNodeName) { + handlePreviewNodeMaterialChange(selectedPreviewNodeName, materialId); + return; + } + + if (previewNodeNames.length === 0) return; + + const nextMaterialIds = { ...effectiveModel3dNodeMaterialIds }; + previewNodeNames.forEach((nodeName) => { + if (materialId) { + nextMaterialIds[nodeName] = materialId; + } else { + delete nextMaterialIds[nodeName]; + } + }); + persistPreviewNodeMaterialIds(nextMaterialIds); + }, + [ + effectiveModel3dNodeMaterialIds, + handlePreviewNodeMaterialChange, + isReadOnly, + persistPreviewNodeMaterialIds, + previewMaterialApplyTarget, + previewNodeNames, + selectedPreviewNodeName, + ], + ); + + const closePreviewContextMenu = useCallback(() => { + setPreviewContextMenuState((current) => ({ ...current, visible: false })); + setActivePreviewSubmenu(null); + }, []); + + const persistPreviewUvConfig = useCallback( + (nextUvConfig: AssetVariantUvConfig | null) => { + const normalizedConfig = normalizeAssetVariantUvConfig(nextUvConfig); + + if (selectedSupplementSourceSku) { + setSkuItems((current) => + current.map((item) => + item.id === selectedSupplementSourceSku.id + ? { + ...item, + model3dUvConfig: normalizedConfig, + } + : item, + ), + ); + return; + } + + if (selectedStyleKey) { + setStyleDetails((current) => ({ + ...current, + [selectedStyleKey]: { + ...(current[selectedStyleKey] ?? EMPTY_STYLE_DETAIL), + model3dUvConfig: normalizedConfig, + }, + })); + } + }, + [selectedStyleKey, selectedSupplementSourceSku], + ); + + const handlePatchPreviewUvEditorForm = useCallback( + (patch: Partial) => { + if (!canEditPreviewModelMaterial) return; + + const nextForm = { + ...previewUvEditorFormRef.current, + ...patch, + }; + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + + const projectionAxis = nextForm.textureProjectionAxis || null; + const nextConfig = updateAssetVariantUvConfig( + effectiveModel3dUvConfig, + { nodeName: activePreviewUvNodeName, projectionAxis }, + () => parsePreviewUvEditorFormState(nextForm), + ); + persistPreviewUvConfig(nextConfig); + }, + [ + activePreviewUvNodeName, + canEditPreviewModelMaterial, + effectiveModel3dUvConfig, + persistPreviewUvConfig, + ], + ); + + const handleSelectPreviewUvProjectionAxis = useCallback( + (projectionAxis: "" | AssetVariantUvProjectionAxis) => { + if (!canEditPreviewModelMaterial) return; + + const nextConfig = updateAssetVariantUvConfig( + effectiveModel3dUvConfig, + { nodeName: activePreviewUvNodeName }, + (currentBaseConfig) => { + const nextBaseConfig = { ...(currentBaseConfig ?? {}) }; + + if (projectionAxis) { + nextBaseConfig.textureProjectionAxis = projectionAxis; + } else { + delete nextBaseConfig.textureProjectionAxis; + } + + return Object.keys(nextBaseConfig).length > 0 + ? nextBaseConfig + : null; + }, + ); + persistPreviewUvConfig(nextConfig); + + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(nextConfig, { + nodeName: activePreviewUvNodeName, + projectionAxis: projectionAxis || null, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, + [ + activePreviewUvNodeName, + canEditPreviewModelMaterial, + effectiveModel3dUvConfig, + persistPreviewUvConfig, + ], + ); + + const handleResetPreviewUvEditor = useCallback(() => { + const nextForm = createPreviewUvEditorFormState( + getAssetVariantUvSurfaceConfig(effectiveModel3dUvConfig, { + nodeName: activePreviewUvNodeName, + }), + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + }, [activePreviewUvNodeName, effectiveModel3dUvConfig]); + + const handleClearPreviewUvEditor = useCallback(() => { + if (!canEditPreviewModelMaterial) return; + + const projectionAxis = previewUvEditorFormRef.current.textureProjectionAxis; + const nextForm = createPreviewUvEditorFormState( + projectionAxis ? { textureProjectionAxis: projectionAxis } : null, + ); + previewUvEditorFormRef.current = nextForm; + setPreviewUvEditorForm(nextForm); + + const nextConfig = updateAssetVariantUvConfig( + effectiveModel3dUvConfig, + { + nodeName: activePreviewUvNodeName, + projectionAxis: projectionAxis || null, + }, + () => null, + ); + persistPreviewUvConfig(nextConfig); + }, [ + activePreviewUvNodeName, + canEditPreviewModelMaterial, + effectiveModel3dUvConfig, + persistPreviewUvConfig, + ]); + + const handleKeepPreviewUvDraft = useCallback(() => { + if (!canEditPreviewModelMaterial) return; + setSaveMessage("渲染参数已暂存"); + setSaveMessageType("success"); + closePreviewContextMenu(); + }, [canEditPreviewModelMaterial, closePreviewContextMenu]); + + const previewContextMenuPosition = useMemo( + () => calculateMenuPosition(previewContextMenuState.x, previewContextMenuState.y, 180, 92), + [previewContextMenuState.x, previewContextMenuState.y], + ); + + const previewUvSettingsPanelPosition = useMemo(() => { + const panelWidth = 264; + const panelHeight = 326; + const viewportGap = 12; + const menuGap = 12; + const rightSideLeft = 180 + menuGap; + const viewportHeight = + typeof window !== "undefined" ? window.innerHeight : 900; + const viewportWidth = + typeof window !== "undefined" ? window.innerWidth : 1440; + const minTop = viewportGap - previewContextMenuPosition.top; + const maxTop = + viewportHeight - previewContextMenuPosition.top - panelHeight - viewportGap; + const top = Math.max(minTop, Math.min(0, maxTop)); + const availableHeight = + viewportHeight - previewContextMenuPosition.top - top - viewportGap; + const maxHeight = Math.max(220, Math.min(panelHeight, availableHeight)); + + if ( + previewContextMenuPosition.left + rightSideLeft + panelWidth > + viewportWidth - viewportGap + ) { + return { + left: -panelWidth - menuGap, + top, + maxHeight, + }; + } + + return { + left: rightSideLeft, + top, + maxHeight, + }; + }, [previewContextMenuPosition.left, previewContextMenuPosition.top]); + + const previewMaterialSettingsPanelPosition = useMemo(() => { + const panelWidth = 180; + const panelHeight = 390; + const viewportGap = 12; + const menuGap = 12; + const rightSideLeft = 180 + menuGap; + const viewportHeight = + typeof window !== "undefined" ? window.innerHeight : 900; + const viewportWidth = + typeof window !== "undefined" ? window.innerWidth : 1440; + const minTop = viewportGap - previewContextMenuPosition.top; + const maxTop = + viewportHeight - previewContextMenuPosition.top - panelHeight - viewportGap; + const top = Math.max(minTop, Math.min(0, maxTop)); + const availableHeight = + viewportHeight - previewContextMenuPosition.top - top - viewportGap; + const maxHeight = Math.max(260, Math.min(panelHeight, availableHeight)); + + if ( + previewContextMenuPosition.left + rightSideLeft + panelWidth > + viewportWidth - viewportGap + ) { + return { + left: -panelWidth - menuGap, + top, + maxHeight, + }; + } + + return { + left: rightSideLeft, + top, + maxHeight, + }; + }, [previewContextMenuPosition.left, previewContextMenuPosition.top]); + + const previewContextMenuGroups = useMemo( + () => + canEditPreviewModelMaterial && model3dPreviewUrl + ? [ + { + id: "preview-render", + items: [ + { + id: "preview-material-settings", + label: "花色", + submenu: "preview-material", + }, + { + id: "preview-uv-settings", + label: "渲染参数", + submenu: "preview-uv", + }, + ], + }, + ] + : [], + [canEditPreviewModelMaterial, model3dPreviewUrl], + ); + + const handlePreviewContextMenuItemClick = useCallback( + (item: ContextMenuItem) => { + if (item.id === "preview-material-settings") { + setActivePreviewSubmenu("preview-material"); + return; + } + if (item.id === "preview-uv-settings") { + setActivePreviewSubmenu("preview-uv"); + } + }, + [], + ); + + const handlePreviewContextMenuHover = useCallback((item: ContextMenuItem) => { + setActivePreviewSubmenu( + item.submenu === "preview-material" || item.submenu === "preview-uv" + ? item.submenu + : null, + ); + }, []); + + useEffect(() => { + if (!previewContextMenuState.visible) return; + + const handleClose = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (target?.closest('[data-context-menu-root="true"]')) return; + closePreviewContextMenu(); + }; + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePreviewContextMenu(); + } + }; + + document.addEventListener("mousedown", handleClose); + document.addEventListener("keydown", handleEsc); + + return () => { + document.removeEventListener("mousedown", handleClose); + document.removeEventListener("keydown", handleEsc); + }; + }, [closePreviewContextMenu, previewContextMenuState.visible]); + + useEffect(() => { + if (canEditPreviewModelMaterial && model3dPreviewUrl) return; + closePreviewContextMenu(); + }, [canEditPreviewModelMaterial, closePreviewContextMenu, model3dPreviewUrl]); + function setFormValuesAndRef(updater: (current: FormValues) => FormValues) { setFormValues((current) => { const nextValues = updater(current); @@ -14696,6 +15696,21 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + function handleBatchConfirmProductPricingPush() { + if (isReadOnly || isProductPricingNodeLocked) return; + if (displayedProductPricingRows.length === 0) return; + if (!displayedProductPricingRowsReadyForPush) { + setSaveMessage("请先填写当前批次要推送 SKU 的成本价"); + setSaveMessageType("error"); + return; + } + if (!PRODUCT_PRICING_JST_PUSH_ENABLED) { + setSaveMessage("聚水潭接口未接入,暂不可批量确认推送"); + setSaveMessageType("error"); + return; + } + } + function updateProductLimitRow( skuId: string, key: Exclude, @@ -16339,10 +17354,50 @@ export default function NonIntegratedDetailClient({ })(), ) : undefined; + const confirmedReviewSheet = + nextReviewRounds?.find((round) => round.id === effectiveSelectedReviewRoundId) + ?.reviewSheet ?? null; + let nextFinishedSampleShipmentRows: + | NonIntegratedFinishedSampleShipmentRow[] + | undefined; + + if (category === REVIEW_WORKSPACE_CATEGORY) { + const confirmedReviewBarcode = confirmedReviewSheet?.barcode.trim() ?? ""; + const confirmedReviewResult = confirmedReviewSheet?.reviewResult.trim() ?? ""; + const isValidReviewResult = REVIEW_RESULT_OPTIONS.includes( + confirmedReviewResult as (typeof REVIEW_RESULT_OPTIONS)[number], + ); + + if (!isValidReviewResult) { + setSaveMessage("请先选择审样结果,再确认评审资料。"); + setSaveMessageType("error"); + return; + } + + if (confirmedReviewResult === REVIEW_RESULT_NO_MODIFICATION) { + const autoShipmentRows = buildNoModificationFinishedSampleShipmentRows( + derivedSkuItems, + confirmedReviewBarcode, + ); + if (!autoShipmentRows) { + setSaveMessage("当前审样条码没有匹配到 SKU 条码,暂不能自动完成成品寄样。"); + setSaveMessageType("error"); + return; + } + + nextFinishedSampleShipmentRows = autoShipmentRows; + nextConfirmations[FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY] = true; + nextApprovedAt[FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY] = approvedAt; + } + } + setDocumentWorkspaceConfirmations(nextConfirmations); setDocumentWorkspaceApprovedAt(nextApprovedAt); if (nextReviewRounds) { setReviewRounds(nextReviewRounds); + if (nextFinishedSampleShipmentRows) { + setFinishedSampleShipmentRows(nextFinishedSampleShipmentRows); + } if (documentWorkspaceRevisionSignatureRef.current) { documentWorkspaceRevisionSignatureRef.current = { ...documentWorkspaceRevisionSignatureRef.current, @@ -16353,6 +17408,13 @@ export default function NonIntegratedDetailClient({ REVIEW_WORKSPACE_CATEGORY, ), }), + ...(nextFinishedSampleShipmentRows + ? { + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: JSON.stringify( + nextFinishedSampleShipmentRows, + ), + } + : {}), }; } } @@ -16364,6 +17426,9 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations: nextConfirmations, documentWorkspaceApprovedAt: nextApprovedAt, ...(nextReviewRounds ? { reviewRounds: nextReviewRounds } : {}), + ...(nextFinishedSampleShipmentRows + ? { finishedSampleShipmentRows: nextFinishedSampleShipmentRows } + : {}), }, }); } @@ -16412,11 +17477,16 @@ export default function NonIntegratedDetailClient({ async function handleConfirmProductPricingRows() { if (isReadOnly || displayedProductPricingRows.length === 0) return; - if (!displayedProductPricingRowsComplete) { + if (!allProductPricingRowsComplete) { setSaveMessage("请先填完整产品核价数据"); setSaveMessageType("error"); return; } + if (!allProductPricingRowsSystemEntered) { + setSaveMessage("请先将全部 SKU 确认推送并录入系统"); + setSaveMessageType("error"); + return; + } if (displayedProductPricingIsConfirmed) return; await handleConfirmDocumentWorkspaceCategory( @@ -17693,6 +18763,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dUvConfig: null, }, })); } else if (selectedSupplementSourceSku) { @@ -17705,6 +18776,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dUvConfig: null, } : item, ), @@ -17718,6 +18790,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dUvConfig: null, }, })); } @@ -18449,7 +19522,7 @@ export default function NonIntegratedDetailClient({ function updateSelectedReviewSheet( updater: (current: NonIntegratedReviewSheet) => NonIntegratedReviewSheet, ) { - if (isReadOnly || !effectiveSelectedReviewRoundId) return; + if (!canEditReviewSheet || !effectiveSelectedReviewRoundId) return; setReviewRounds((current) => current.map((round) => @@ -18466,7 +19539,115 @@ export default function NonIntegratedDetailClient({ } function updateReviewSheetField(key: ReviewSheetFieldKey, value: string) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; + if ( + key === "reviewResult" && + isReviewNodeLocked && + reviewSheet.reviewResult.trim() + ) + return; + + if ( + isReviewNodeLocked && + (key === "barcode" || key === "reviewResult") + ) { + const nextReviewRounds = reviewRounds.map((round) => + round.id === effectiveSelectedReviewRoundId + ? { + ...round, + reviewSheet: { + ...round.reviewSheet, + [key]: value, + }, + } + : round, + ); + const selectedLockedReviewResult = + key === "reviewResult" ? value.trim() : (reviewSheet.reviewResult?.trim() ?? ""); + let nextConfirmations = documentWorkspaceConfirmations; + let nextApprovedAt = documentWorkspaceApprovedAt; + let nextFinishedSampleShipmentRows: + | NonIntegratedFinishedSampleShipmentRow[] + | undefined; + + if ( + key === "reviewResult" && + !REVIEW_RESULT_OPTIONS.includes( + selectedLockedReviewResult as (typeof REVIEW_RESULT_OPTIONS)[number], + ) + ) { + setSaveMessage("请选择有效的审样结果。"); + setSaveMessageType("error"); + return; + } + + if (selectedLockedReviewResult === REVIEW_RESULT_NO_MODIFICATION) { + const autoShipmentRows = buildNoModificationFinishedSampleShipmentRows( + derivedSkuItems, + key === "barcode" ? value : reviewSheet.barcode, + ); + if (!autoShipmentRows) { + setSaveMessage("当前条码没有匹配到 SKU 条码,请选择已有 SKU 条码。"); + setSaveMessageType("error"); + return; + } + + nextFinishedSampleShipmentRows = autoShipmentRows; + nextConfirmations = { + ...documentWorkspaceConfirmations, + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: true, + }; + nextApprovedAt = { + ...documentWorkspaceApprovedAt, + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: + documentWorkspaceApprovedAt[FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY] ?? + new Date().toISOString(), + }; + setFinishedSampleShipmentRows(nextFinishedSampleShipmentRows); + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); + } + + setReviewRounds(nextReviewRounds); + if (documentWorkspaceRevisionSignatureRef.current) { + documentWorkspaceRevisionSignatureRef.current = { + ...documentWorkspaceRevisionSignatureRef.current, + [REVIEW_WORKSPACE_CATEGORY]: JSON.stringify({ + reviewRounds: nextReviewRounds, + files: buildDocumentWorkspaceRevisionFileEntries( + uploadFiles, + REVIEW_WORKSPACE_CATEGORY, + ), + }), + ...(nextFinishedSampleShipmentRows + ? { + [FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY]: JSON.stringify( + nextFinishedSampleShipmentRows, + ), + } + : {}), + }; + } + setSaveMessage(""); + setSaveMessageType("success"); + void persistEditedProduct({ + successMessage: + key === "barcode" ? "已更新评审条码绑定" : "已补充审样结果", + errorFallbackMessage: + key === "barcode" + ? "评审条码绑定保存失败,请稍后再试" + : "审样结果保存失败,请稍后再试", + overrides: { + reviewRounds: nextReviewRounds, + documentWorkspaceConfirmations: nextConfirmations, + documentWorkspaceApprovedAt: nextApprovedAt, + ...(nextFinishedSampleShipmentRows + ? { finishedSampleShipmentRows: nextFinishedSampleShipmentRows } + : {}), + }, + }); + return; + } updateSelectedReviewSheet((current) => ({ ...current, @@ -18479,7 +19660,7 @@ export default function NonIntegratedDetailClient({ key: ReviewIssueFieldKey, value: string, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => ({ ...current, @@ -18518,7 +19699,7 @@ export default function NonIntegratedDetailClient({ } function handleAddReviewIssue() { - if (isReadOnly) return; + if (!canEditReviewSheet) return; const nextIssue = createEmptyReviewIssue(); updateSelectedReviewSheet((current) => ({ @@ -18529,7 +19710,7 @@ export default function NonIntegratedDetailClient({ } function handleDeleteReviewIssue(issueId: string) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => { const remainingIssues = current.issues.filter( @@ -18550,7 +19731,7 @@ export default function NonIntegratedDetailClient({ } function handleAddReviewRound() { - if (isReadOnly) return; + if (!canEditReviewSheet) return; if ( !window.confirm( "确定新增轮次吗?\n点击确认后会新增一轮空白评审;真的确认吗?", @@ -18612,7 +19793,7 @@ export default function NonIntegratedDetailClient({ async function handleReviewSheetProductImageChange( event: ChangeEvent, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; const file = event.target.files?.[0]; if (!file) return; @@ -18641,14 +19822,12 @@ export default function NonIntegratedDetailClient({ event.target.value = ""; } - async function handleReviewIssueImageChange( + async function uploadReviewIssueImageFiles( issueId: string, target: ReviewIssueImageTarget, - event: ChangeEvent, + files: File[], ) { - if (isReadOnly) return; - - const files = Array.from(event.target.files ?? []); + if (!canEditReviewSheet) return; if (files.length === 0) return; try { @@ -18703,22 +19882,51 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("error"); setUploadProgress(null); } + } + + async function handleReviewIssueImageChange( + issueId: string, + target: ReviewIssueImageTarget, + event: ChangeEvent, + ) { + await uploadReviewIssueImageFiles( + issueId, + target, + Array.from(event.target.files ?? []), + ); event.target.value = ""; } + async function handleReviewIssueImagePaste( + issueId: string, + target: ReviewIssueImageTarget, + event: ClipboardEvent, + ) { + if (!canEditReviewSheet) return; + + const files = getImageFilesFromClipboardData(event.clipboardData); + if (files.length === 0) return; + + event.preventDefault(); + await uploadReviewIssueImageFiles(issueId, target, files); + } + function handleDeleteReviewIssueImage( issueId: string, target: ReviewIssueImageTarget, imageId: string, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => ({ ...current, issues: updateReviewIssueList(current.issues, issueId, (issue) => { const imageKey = target === "issue" ? "issueImages" : "referenceImages"; + const primaryImageKey = target === "issue" ? "issueImage" : "referenceImage"; + const primaryImageNameKey = + target === "issue" ? "issueImageName" : "referenceImageName"; const currentImages = normalizeReviewIssueImages( issue[imageKey], target === "issue" ? issue.issueImage : issue.referenceImage, @@ -18738,6 +19946,8 @@ export default function NonIntegratedDetailClient({ return { ...issue, + [primaryImageKey]: "", + [primaryImageNameKey]: "", [imageKey]: nextImages, }; }), @@ -18749,7 +19959,7 @@ export default function NonIntegratedDetailClient({ target: ReviewIssueImageTarget, imageId: string, ) { - if (isReadOnly) return; + if (!canEditReviewSheet) return; updateSelectedReviewSheet((current) => ({ ...current, @@ -18794,6 +20004,8 @@ export default function NonIntegratedDetailClient({ productImageName: reviewSheet.productImageName.trim(), size: reviewSheet.size.trim(), material: reviewSheet.material.trim(), + barcode: reviewSheet.barcode?.trim() ?? "", + reviewResult: reviewSheet.reviewResult?.trim() ?? "", color: reviewSheet.color.trim(), issues: reviewSheet.issues .map((issue) => syncReviewIssuePrimaryImages(issue)) @@ -19515,10 +20727,11 @@ export default function NonIntegratedDetailClient({ onChange={(event) => void handleUploadPackagingFiles(event)} /> packagingModel3dInputRef.current?.click()} disabled={!selectedPackagingStyleEntry} > - 上传 + 上传模型 packagingModel3dInputRef.current?.click()} + > + 上传模型 + + ) : null + } />

@@ -21371,7 +22594,6 @@ export default function NonIntegratedDetailClient({ } function renderProductPricingWorkspace() { - const isJstPricingPushEnabled = false; const renderReadOnlyCell = (value: string) => (
{value || "-"} @@ -21434,8 +22656,7 @@ export default function NonIntegratedDetailClient({ {displayedProductPricingRows.map((row) => { const isSystemEntered = - isJstPricingPushEnabled && - row.systemEnteredAt.trim().length > 0; + isProductPricingRowSystemEntered(row); const pricingReadyForFuturePush = row.costPrice.trim().length > 0; @@ -21514,7 +22735,7 @@ export default function NonIntegratedDetailClient({ title={ isSystemEntered ? "当前 SKU 已录入系统,后续以聚水潭回传状态为准" - : isJstPricingPushEnabled && + : PRODUCT_PRICING_JST_PUSH_ENABLED && pricingReadyForFuturePush ? "请由采购/核价确认推送" : pricingReadyForFuturePush @@ -24075,7 +25296,21 @@ function renderFactoryQualityWorkspace() { const previewImages = images.slice(0, 2); return ( -
+
{ + if (canEditReviewSheet) { + event.currentTarget.focus(); + } + }} + onPaste={(event) => + void handleReviewIssueImagePaste(issue.id, target, event) + } + className="flex min-h-[74px] flex-col items-center justify-center gap-1 rounded-apple-lg outline-none transition focus-visible:ring-2 focus-visible:ring-apple-blue/35" + > {images.length > 0 ? ( - ) : isReadOnly ? ( + ) : !canEditReviewSheet ? ( 未上传 ) : (