From a8b5c8475992ec4102704e4cdb164fd84f34e573 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 16 Oct 2026 17:26:59 +0800 Subject: [PATCH] feat(intake): notify quality inspectors and keep product photo uploads available --- .../migration.sql | 13 ++ furniture-3d-system/prisma/schema.prisma | 17 ++ .../[id]/photo-upload-request/route.ts | 28 +++ .../[id]/photo-upload-request/upload/route.ts | 17 ++ .../NonIntegratedDetailClient.media.test.tsx | 21 +- .../NonIntegratedDetailClient.tsx | 19 ++ .../useNonIntegratedPermissionState.ts | 4 + .../documents/ProductPhotoPreview.tsx | 62 ++++++ .../documents/ProductPhotoSourcePanel.tsx | 111 +++++++++++ .../documents/ProductPhotoWorkspace.tsx | 185 +++--------------- .../ProductPhotoRequestContext.tsx | 40 ++++ .../QualityProductPhotoUpload.tsx | 87 ++++++++ .../SampleMeasurementNotifyButton.module.css | 39 ++++ .../SampleMeasurementNotifyButton.tsx | 82 ++++++++ .../SampleMeasurementHeaderActions.tsx | 2 + .../src/lib/intake-product-photo-request.ts | 32 +++ ...intake-product-photo-request.repository.ts | 59 ++++++ ...ntake-product-photo-access.service.test.ts | 53 +++++ ...product-photo-notification.service.test.ts | 102 ++++++++++ ...ntake-product-photo-upload.service.test.ts | 91 +++++++++ .../intake-non-integrated-product.service.ts | 4 +- .../intake-non-integrated-scope.service.ts | 4 + .../intake-product-photo-access.service.ts | 17 ++ ...take-product-photo-notification.service.ts | 84 ++++++++ .../intake-product-photo-recipient.policy.ts | 17 ++ .../intake-product-photo-upload.service.ts | 93 +++++++++ 26 files changed, 1122 insertions(+), 161 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20261016150000_add_intake_product_photo_upload_grant/migration.sql create mode 100644 furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/route.ts create mode 100644 furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/upload/route.ts create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoPreview.tsx create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoSourcePanel.tsx create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/ProductPhotoRequestContext.tsx create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/QualityProductPhotoUpload.tsx create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.module.css create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.tsx create mode 100644 furniture-3d-system/src/lib/intake-product-photo-request.ts create mode 100644 furniture-3d-system/src/server/repositories/intake-product-photo-request.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/intake-product-photo-access.service.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/intake-product-photo-notification.service.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/intake-product-photo-upload.service.test.ts create mode 100644 furniture-3d-system/src/server/services/intake-product-photo-access.service.ts create mode 100644 furniture-3d-system/src/server/services/intake-product-photo-notification.service.ts create mode 100644 furniture-3d-system/src/server/services/intake-product-photo-recipient.policy.ts create mode 100644 furniture-3d-system/src/server/services/intake-product-photo-upload.service.ts diff --git a/furniture-3d-system/prisma/migrations/20261016150000_add_intake_product_photo_upload_grant/migration.sql b/furniture-3d-system/prisma/migrations/20261016150000_add_intake_product_photo_upload_grant/migration.sql new file mode 100644 index 00000000..2f747e3d --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261016150000_add_intake_product_photo_upload_grant/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "IntakeProductPhotoUploadGrant" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "grantedBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "IntakeProductPhotoUploadGrant_pkey" PRIMARY KEY ("id"), + CONSTRAINT "IntakeProductPhotoUploadGrant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "IntakeProductPhotoUploadGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "IntakeProductPhotoUploadGrant_grantedBy_fkey" FOREIGN KEY ("grantedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "IntakeProductPhotoUploadGrant_productId_userId_key" ON "IntakeProductPhotoUploadGrant"("productId", "userId"); +CREATE INDEX "IntakeProductPhotoUploadGrant_userId_idx" ON "IntakeProductPhotoUploadGrant"("userId"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index abd8fda9..2173a6ba 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -482,6 +482,7 @@ model IntakeNonIntegratedProduct { fixedVirtualInventoryTasks IntakeJushuitanFixedVirtualInventoryTask[] profile IntakeNonIntegratedProductProfile? modelAssets IntakeProductModelAsset[] + photoUploadGrants IntakeProductPhotoUploadGrant[] @@index([spu]) @@index([spuBase]) @@ -492,6 +493,20 @@ model IntakeNonIntegratedProduct { @@index([deletedAt, createdAt]) } +model IntakeProductPhotoUploadGrant { + id String @id + productId String + userId String + grantedBy String + createdAt DateTime @default(now()) + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + user User @relation("IntakePhotoUploadRecipient", fields: [userId], references: [id], onDelete: Cascade) + grantor User @relation("IntakePhotoUploadGrantor", fields: [grantedBy], references: [id], onDelete: Restrict) + + @@unique([productId, userId]) + @@index([userId]) +} + model BarcodePrintImportRecord { id String @id @default(cuid()) kind String @@ -3437,6 +3452,8 @@ model User { // 关联 createdProducts Product[] @relation("ProductCreator") + intakePhotoUploadGrants IntakeProductPhotoUploadGrant[] @relation("IntakePhotoUploadRecipient") + issuedIntakePhotoUploadGrants IntakeProductPhotoUploadGrant[] @relation("IntakePhotoUploadGrantor") ownedSKUs ProductSKU[] @relation("SKUOwner") createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator") createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator") diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/route.ts new file mode 100644 index 00000000..452dd3b1 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { ErrorCode } from '@/server/core/errors' +import { NotificationDeliveryError } from '@/server/services/notification-delivery.service' +import { intakeProductPhotoNotificationService } from '@/server/services/intake-product-photo-notification.service' + +type RouteContext = { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, context: RouteContext) { + return handleRequest(request, async ({ user, requestId }) => { + const { id } = await context.params + const data = await intakeProductPhotoNotificationService.getAccess(id, user, request.nextUrl.searchParams.get('recipients') === 'true') + return jsonSuccess(data, 200, requestId) + }, { requireAuth: true }) +} + +export async function POST(request: NextRequest, context: RouteContext) { + return handleRequest(request, async ({ user, requestId }) => { + const { id } = await context.params + const body: unknown = await request.json().catch(() => null) + try { + return jsonSuccess(await intakeProductPhotoNotificationService.send(id, body, user), 200, requestId) + } catch (error) { + if (error instanceof NotificationDeliveryError) return jsonError(ErrorCode.VALIDATION_FAILED, error.message, 400, requestId) + throw error + } + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/upload/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/upload/route.ts new file mode 100644 index 00000000..04ade38c --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/photo-upload-request/upload/route.ts @@ -0,0 +1,17 @@ +import { NextRequest } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { intakeProductPhotoUploadService } from '@/server/services/intake-product-photo-upload.service' + +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + return handleRequest(request, async ({ user, requestId }) => { + const { id } = await context.params + const form = await request.formData() + const file = form.get('file') + if (!(file instanceof File)) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择产品实拍图片') + let metadata: unknown + try { metadata = JSON.parse(String(form.get('metadata') || 'null')) } + catch { throw new AppError(ErrorCode.VALIDATION_FAILED, '图片位置信息格式不正确') } + return jsonSuccess(await intakeProductPhotoUploadService.upload(id, file, metadata, user), 201, requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.media.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.media.test.tsx index 0c05debb..29add28b 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.media.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.media.test.tsx @@ -263,7 +263,7 @@ describe("NonIntegratedDetailClient media workspaces", () => { ).toHaveAttribute("alt", "IMG_0001.JPG"); }); - it("uploads grouped product photos from the empty detail panel", async () => { + it.each(["detail", "quality-check", "competitor-sample", "competitor-disassembly"])("keeps the grouped product photos upload entry available for %s", async (panel) => { const actor = userEvent.setup(); replaceNonIntegratedProducts([ @@ -282,9 +282,9 @@ describe("NonIntegratedDetailClient media workspaces", () => { await actor.click( screen.getByTestId("document-workspace-trigger-产品实拍图"), ); - await actor.click(screen.getByTestId("product-photo-panel-tab-detail")); + await actor.click(screen.getByTestId(`product-photo-panel-tab-${panel}`)); await actor.click( - screen.getByTestId("product-photo-empty-panel-upload-detail"), + screen.getByTestId(`product-photo-panel-upload-${panel}`), ); await actor.upload( screen.getByTestId("product-photo-detail-upload-input"), @@ -295,6 +295,21 @@ describe("NonIntegratedDetailClient media workspaces", () => { expect( within(detailList).getByText("detail-click-upload.png"), ).toBeInTheDocument(); + expect(screen.getByTestId(`product-photo-panel-upload-${panel}`)).toHaveTextContent("已上传 1 张,点击继续添加"); + expect(within(screen.getByTestId(`product-photo-panel-upload-${panel}`)).queryByRole("img")).not.toBeInTheDocument(); + expect(within(screen.getByTestId("product-photo-preview")).getByTestId("product-photo-detail-list")).toBe(detailList); + + await actor.click(screen.getByTestId(`product-photo-panel-upload-${panel}`)); + await actor.upload( + screen.getByTestId("product-photo-detail-upload-input"), + new File(["second"], "second-photo.png", { type: "image/png" }), + ); + await waitFor(() => expect(within(detailList).getAllByRole("button")).toHaveLength(2)); + expect(screen.getByTestId(`product-photo-panel-upload-${panel}`)).toHaveTextContent("已上传 2 张,点击继续添加"); + await actor.click(within(detailList).getByRole("button", { name: /detail-click-upload.png/ })); + expect(screen.getByTestId("document-workspace-image-preview")).toHaveAttribute("alt", "detail-click-upload.png"); + await actor.click(within(detailList).getByRole("button", { name: /second-photo.png/ })); + expect(screen.getByTestId("document-workspace-image-preview")).toHaveAttribute("alt", "second-photo.png"); }); it("hides product photo styles that are not present in sku models", 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 91714a1a..dc6ade0e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -1,5 +1,8 @@ "use client"; +import { useEffect } from 'react'; +import { ProductPhotoRequestProvider, useProductPhotoRequest } from './workspaces/photo-request/ProductPhotoRequestContext'; + import { buildNonIntegratedDetailRenderers } from "./buildNonIntegratedDetailRenderers"; import { buildNonIntegratedDetailViewModel } from "./buildNonIntegratedDetailViewModel"; import { NonIntegratedDetailView } from "./components/NonIntegratedDetailView"; @@ -14,8 +17,24 @@ export { buildNonIntegratedBomDraftStorageKey } from "./workspaces/bom/bomDetail export default function NonIntegratedDetailClient( props: NonIntegratedDetailClientProps, ) { + return + + ; +} + +function NonIntegratedDetailContent(props: NonIntegratedDetailClientProps) { const base = useNonIntegratedDetailBaseState(props); const controllers = useNonIntegratedDetailDomainControllers(props, base); + const { uploadedFiles } = useProductPhotoRequest(); + const setUploadFiles = base.documentState.setUploadFiles; + useEffect(() => { + if (!uploadedFiles.length) return; + setUploadFiles((current) => { + const ids = new Set(current.map((file) => file.id)); + const additions = uploadedFiles.filter((file) => !ids.has(file.id)); + return additions.length ? [...current, ...additions] : current; + }); + }, [uploadedFiles, setUploadFiles]); if ( props.mode !== "create" && diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts index f19c97ae..33d1944b 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { useProductPhotoRequest } from './workspaces/photo-request/ProductPhotoRequestContext'; import { OPENING_REFERENCE_WORKSPACE_CATEGORY } from "./workspaces/documents/documentWorkspaceConfig"; import type { FormValues } from "./workspaces/basic-info/productFormValueHelpers"; import type { DocumentWorkspaceCategory } from "./workspaces/documents/documentWorkspaceHelpers"; @@ -54,6 +55,7 @@ export function useNonIntegratedPermissionState({ linkEntryView, openingReference, }: UseNonIntegratedPermissionStateParams) { + const { access: photoRequestAccess } = useProductPhotoRequest(); const [nodePermissionOverrides, setNodePermissionOverrides] = useState({}); const hasCurrentUserContext = Boolean(currentUserRole); @@ -247,6 +249,7 @@ export function useNonIntegratedPermissionState({ )); const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback( (category: DocumentWorkspaceCategory) => { + if (category === '产品实拍图' && photoRequestAccess?.canUpload) return true; if (linkEntryView) { return category === OPENING_REFERENCE_WORKSPACE_CATEGORY; } @@ -268,6 +271,7 @@ export function useNonIntegratedPermissionState({ isCurrentUserAdmin, isCurrentUserFullDetailEditor, linkEntryView, + photoRequestAccess?.canUpload, ], ); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoPreview.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoPreview.tsx new file mode 100644 index 00000000..7cee45bc --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoPreview.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage"; +import { QualityProductPhotoUpload } from "../photo-request/QualityProductPhotoUpload"; +import type { ProductPhotoFileGroup, ProductPhotoGroup, ProductPhotoSelection } from "./documentWorkspaceHelpers"; +import { getUploadFileTypeLabel, isImageUploadFile } from "./uploadFilePreview"; + +type ProductPhotoPreviewProps = { + activePanel: ProductPhotoGroup; + files: NonIntegratedUploadFile[]; + selectedTarget: ProductPhotoSelection; + isReadOnly: boolean; + barcode: string; + styleKey: string; + title: string; + description: string; + children: ReactNode; + onSelectGroupedFile: (group: ProductPhotoFileGroup, fileId: string) => void; +}; + +export function ProductPhotoPreview({ + activePanel, files, selectedTarget, isReadOnly, barcode, styleKey, + title, description, children, onSelectGroupedFile, +}: ProductPhotoPreviewProps) { + return ( +
+
+ {isReadOnly && activePanel === "nine-view" && } +

{title}

+

{description}

+
+ {activePanel !== "nine-view" && files.length > 0 && ( +
+ {files.map((file) => { + const isActive = selectedTarget.group === activePanel && selectedTarget.fileId === file.id; + return ( + + ); + })} +
+ )} +
{children}
+
+ ); +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoSourcePanel.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoSourcePanel.tsx new file mode 100644 index 00000000..5d4e94d1 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoSourcePanel.tsx @@ -0,0 +1,111 @@ +import { Camera } from "lucide-react"; +import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage"; +import { QualityProductPhotoUpload } from "../photo-request/QualityProductPhotoUpload"; +import { useProductPhotoRequest } from "../photo-request/ProductPhotoRequestContext"; +import type { ProductPhotoWorkspaceData } from "../style-assets/styleWorkspaceEntries"; +import { + PRODUCT_PHOTO_NINE_VIEW_SLOTS, + type ProductPhotoGroup, + type ProductPhotoNineViewSlot, + type ProductPhotoSelection, +} from "./documentWorkspaceHelpers"; +import { isImageUploadFile } from "./uploadFilePreview"; + +type ProductPhotoSourcePanelProps = { + activePanel: ProductPhotoGroup; + activePanelTitle: string; + files: NonIntegratedUploadFile[]; + workspaceData: ProductPhotoWorkspaceData; + selectedTarget: ProductPhotoSelection; + isReadOnly: boolean; + barcode: string; + styleKey: string; + onOpenPanelUpload: (panel: ProductPhotoGroup) => void; + onOpenCurrentUpload: (target: ProductPhotoSelection) => void; + onSelectSlot: (slot: ProductPhotoNineViewSlot) => void; +}; + +export function ProductPhotoSourcePanel({ + activePanel, activePanelTitle, files, workspaceData, selectedTarget, + isReadOnly, barcode, styleKey, onOpenPanelUpload, onOpenCurrentUpload, onSelectSlot, +}: ProductPhotoSourcePanelProps) { + const { access } = useProductPhotoRequest(); + + if (activePanel === "nine-view") { + return ( +
+
+ {PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => { + const slotFile = workspaceData.slotFiles[slot.key]; + const isActive = selectedTarget.group === "nine-view" && selectedTarget.slot === slot.key; + return ( + + ); + })} +
+
+ ); + } + + const uploadEntry = (open: () => void) => ( + + ); + + return ( +
+ {!isReadOnly ? uploadEntry(() => onOpenPanelUpload(activePanel)) : access?.canUpload ? ( + + ) : ( +
+ {files.length ? `已上传 ${files.length} 张,请在预览区查看` : `当前还没有${activePanelTitle}资料。`} +
+ )} +
+ ); +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoWorkspace.tsx index 5127df90..19db0a06 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/documents/ProductPhotoWorkspace.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from "react"; +import { ProductPhotoSourcePanel } from "./ProductPhotoSourcePanel"; +import { ProductPhotoPreview } from "./ProductPhotoPreview"; import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage"; import { - PRODUCT_PHOTO_NINE_VIEW_SLOTS, PRODUCT_PHOTO_SOURCE_PANEL_ORDER, getProductPhotoPanelCompactTitle, type ProductPhotoFileGroup, @@ -10,7 +11,6 @@ import { type ProductPhotoSelection, type ProductPhotoWorkspaceMode, } from "./documentWorkspaceHelpers"; -import { getUploadFileTypeLabel, isImageUploadFile } from "./uploadFilePreview"; import type { ProductPhotoBarcodeEntry, ProductPhotoStyleEntry, @@ -62,7 +62,6 @@ export function ProductPhotoWorkspace({ activePanelFiles, activePanelTitle, selectedTarget, - groupUploadLabel, isReadOnly, previewTitle, previewDescription, @@ -239,163 +238,35 @@ export function ProductPhotoWorkspace({
- {activePanel === "nine-view" ? ( -
-
- {PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => { - const slotFile = workspaceData.slotFiles[slot.key]; - const isActive = - selectedTarget.group === "nine-view" && - selectedTarget.slot === slot.key; - - return ( - - ); - })} -
-
- ) : ( -
- {activePanelFiles.length === 0 ? ( - - ) : ( -
- {activePanelFiles.map((file) => { - const isActive = - selectedTarget.group === activePanel && - selectedTarget.fileId === file.id; - - return ( - - ); - })} -
- )} -
- )} +
-
-
-

- {previewTitle} -

-

- {previewDescription} -

-
-
- {previewContent} -
-
+ + {previewContent} + )} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/ProductPhotoRequestContext.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/ProductPhotoRequestContext.tsx new file mode 100644 index 00000000..55103d6e --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/ProductPhotoRequestContext.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import type { NonIntegratedUploadFile } from '../../nonIntegratedStorage'; +import type { ProductPhotoRequestAccess } from '@/lib/intake-product-photo-request'; + +type PhotoRequestContext = { + productId: string; + access: ProductPhotoRequestAccess | null; + uploadedFiles: NonIntegratedUploadFile[]; + acceptUploadedFile: (file: NonIntegratedUploadFile) => void; +}; +const Context = createContext({ + productId: '', access: null, uploadedFiles: [], acceptUploadedFile: () => {}, +}); + +export function ProductPhotoRequestProvider({ productId, children }: { productId: string; children: ReactNode }) { + return {children}; +} + +function ProductPhotoRequestState({ productId, children }: { productId: string; children: ReactNode }) { + const [access, setAccess] = useState(null); + const [uploadedFiles, setUploadedFiles] = useState([]); + useEffect(() => { + if (!productId) return; + const controller = new AbortController(); + void fetch(`/api/intake/non-integrated/${encodeURIComponent(productId)}/photo-upload-request`, { + cache: 'no-store', signal: controller.signal, + }).then(async (response) => { + const body = await response.json(); + if (!controller.signal.aborted && response.ok && body.success) setAccess(body.data); + }).catch(() => {}); + return () => controller.abort(); + }, [productId]); + return setUploadedFiles((current) => [...current, file]), + }}>{children}; +} + +export const useProductPhotoRequest = () => useContext(Context); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/QualityProductPhotoUpload.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/QualityProductPhotoUpload.tsx new file mode 100644 index 00000000..8297220f --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/QualityProductPhotoUpload.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import Button from '@/components/ui/Button'; +import Modal from '@/components/ui/Modal'; +import { PRODUCT_PHOTO_NINE_VIEW_SLOTS, type ProductPhotoSelection } from '../documents/documentWorkspaceHelpers'; +import { prepareNonIntegratedImageFileForUpload } from '../documents/uploadRecordHelpers'; +import { useProductPhotoRequest } from './ProductPhotoRequestContext'; + +export function QualityProductPhotoUpload({ barcode, styleKey, target, renderTrigger }: { + barcode: string; styleKey: string; target: ProductPhotoSelection; + renderTrigger?: (open: () => void) => ReactNode; +}) { + const { productId, access, acceptUploadedFile } = useProductPhotoRequest(); + const [open, setOpen] = useState(false); + const [files, setFiles] = useState([]); + const [progress, setProgress] = useState(''); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const [failed, setFailed] = useState(false); + const [previews, setPreviews] = useState([]); + + const chooseFiles = useCallback((selected: File[]) => { + setFiles(selected); setPreviews(selected.map((file) => URL.createObjectURL(file))); + setProgress(''); setFailed(false); + }, []); + useEffect(() => () => previews.forEach((url) => URL.revokeObjectURL(url)), [previews]); + useEffect(() => { + if (!open || busy) return; + const paste = (event: ClipboardEvent) => { + const pasted = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/')); + if (pasted.length) { event.preventDefault(); chooseFiles(pasted); } + }; + document.addEventListener('paste', paste); + return () => document.removeEventListener('paste', paste); + }, [open, busy, chooseFiles]); + function close() { if (!busyRef.current) { chooseFiles([]); setOpen(false); } } + async function upload() { + if (busyRef.current || files.length === 0) return; + busyRef.current = true; setBusy(true); setFailed(false); + let succeeded = 0; + const failures: string[] = []; + for (const [index, file] of files.entries()) { + setProgress(`正在上传 ${index + 1}/${files.length}:${file.name}`); + try { + const prepared = await prepareNonIntegratedImageFileForUpload(file); + const form = new FormData(); form.set('file', prepared); + form.set('metadata', JSON.stringify({ barcode, styleKey, group: target.group, + ...(target.group === 'nine-view' ? { slot: target.slot } : {}), + })); + const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(productId)}/photo-upload-request/upload`, { method: 'POST', body: form }); + const body = await response.json(); + if (!response.ok || !body.success) throw new Error(body.error?.message || '上传失败'); + acceptUploadedFile(body.data.file); succeeded += 1; + } catch (error) { failures.push(`${file.name}:${error instanceof Error ? error.message : '上传失败'}`); } + } + setProgress(`上传完成:成功 ${succeeded} 张,失败 ${failures.length} 张。${failures.length ? '\n' + failures.join('\n') : ''}`); + setFailed(failures.length > 0); setFiles([]); setPreviews([]); + busyRef.current = false; setBusy(false); + } + if (!access?.canUpload) return null; + const openUpload = () => { if (barcode) { setProgress(''); setFailed(false); setOpen(true); } }; + const position = target.group === 'nine-view' ? PRODUCT_PHOTO_NINE_VIEW_SLOTS.find((slot) => slot.key === target.slot)?.label : '当前分类'; + return <> + {renderTrigger ? renderTrigger(openUpload) : } + + + + }> +
+

{barcode} · {position}

+ + + {/* Local blob previews must not go through the remote image optimizer. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {previews.length > 0 &&
{previews.map((url) => 待上传实拍预览)}
} + {progress &&

{progress}

} +
+
+ ; +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.module.css b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.module.css new file mode 100644 index 00000000..72f175c8 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.module.css @@ -0,0 +1,39 @@ +/* Component-layer important rules outrank the workspace's Tailwind overrides. */ +@layer components { + .trigger.trigger, + .dialog.dialog button { + background: #ffffff !important; + background-image: none !important; + border-color: #dfe3e7 !important; + color: #525b65 !important; + box-shadow: none !important; + } + + .trigger.trigger:hover, + .dialog.dialog button:hover { + background: #f3f5f7 !important; + } + + .dialog.dialog button[role='combobox'] { + border: 1px solid #dfe3e7 !important; + } + + .dialog.dialog button:disabled { + background: #f4f5f7 !important; + color: #9299a1 !important; + } + + .dialog.dialog button:focus-visible { + outline: 2px solid #becbd6; + outline-offset: 2px; + } + + .dialog.dialog [role='listbox'] { + background: #ffffff !important; + } + + .dialog.dialog [role='option'][aria-selected='true'] { + background: #edf2f6 !important; + color: #435568 !important; + } +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.tsx new file mode 100644 index 00000000..5a727d16 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/photo-request/SampleMeasurementNotifyButton.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useRef, useState } from 'react'; +import Button from '@/components/ui/Button'; +import Modal from '@/components/ui/Modal'; +import Select from '@/components/ui/Select'; +import type { ProductPhotoRequestRecipient } from '@/lib/intake-product-photo-request'; +import { useProductPhotoRequest } from './ProductPhotoRequestContext'; +import styles from './SampleMeasurementNotifyButton.module.css'; + +export function SampleMeasurementNotifyButton() { + const { productId, access } = useProductPhotoRequest(); + const [open, setOpen] = useState(false); + const [recipients, setRecipients] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [loading, setLoading] = useState(false); + const [sending, setSending] = useState(false); + const [message, setMessage] = useState(''); + const [success, setSuccess] = useState(false); + const attempt = useRef(''); + const inFlight = useRef(false); + const endpoint = `/api/intake/non-integrated/${encodeURIComponent(productId)}/photo-upload-request`; + + async function showDialog() { + setOpen(true); setLoading(true); setMessage(''); setSuccess(false); + setSelectedId(''); attempt.current = crypto.randomUUID(); + try { + const response = await fetch(`${endpoint}?recipients=true`, { cache: 'no-store' }); + const body = await response.json(); + if (!response.ok || !body.success) throw new Error(body.error?.message || '质检名单读取失败'); + setRecipients(body.data.recipients); + } catch (error) { setRecipients([]); setMessage(error instanceof Error ? error.message : '质检名单读取失败'); } + finally { setLoading(false); } + } + + async function send() { + if (!selectedId || inFlight.current) return; + inFlight.current = true; setSending(true); setMessage(''); + try { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipientId: selectedId, requestId: attempt.current }), + }); + const body = await response.json(); + if (!response.ok || !body.success) throw new Error(body.error?.message || '通知发送失败,请重试'); + if (['failed', 'expired'].includes(body.data.status)) throw new Error('通知投递失败,请查看通知记录'); + setSuccess(true); + setMessage(`已提交发送给${body.data.recipientName},该人员已获得当前产品实拍上传权限。`); + } catch (error) { setMessage(error instanceof Error ? error.message : '通知发送失败,请重试'); } + finally { inFlight.current = false; setSending(false); } + } + + if (!access?.canNotify) return null; + return <> + + { if (!sending) setOpen(false); }} title="通知质检" size="small" variant="compact" className={styles.dialog} bodyClassName="min-h-[340px]" + footer={<> + {!success && }}> +
+
+ 选择质检 +