diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts new file mode 100644 index 00000000..af25ed19 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts @@ -0,0 +1,243 @@ +import { execFile } from "child_process"; +import { createHash } from "crypto"; +import { existsSync } from "fs"; +import { + copyFile, + mkdir, + mkdtemp, + readdir, + rm, + stat, +} from "fs/promises"; +import { tmpdir } from "os"; +import path from "path"; +import { promisify } from "util"; +import { NextRequest } from "next/server"; +import { + buildNonIntegratedDocumentPreviewUrl, + getNonIntegratedAssetDirectory, + resolveNonIntegratedAssetFilePath, +} from "@/app/intake/non-integrated/nonIntegratedAssetServer"; +import { NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY } from "@/app/intake/non-integrated/nonIntegratedAssetUrl"; +import { AppError, ErrorCode } from "@/server/core/errors"; +import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response"; +import { intakeNonIntegratedProductService } from "@/server/services"; + +export const runtime = "nodejs"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +const execFileAsync = promisify(execFile); +const NON_INTEGRATED_ASSET_DIR = getNonIntegratedAssetDirectory(); +const DOCUMENT_PREVIEW_DIR = path.join( + NON_INTEGRATED_ASSET_DIR, + NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY, +); +const PRESENTATION_EXTENSIONS = new Set([".ppt", ".pptx"]); +const DOCUMENT_PREVIEW_EXTENSIONS = new Set([".pdf", ".ppt", ".pptx"]); +const DOCUMENT_PREVIEW_MAX_PAGES = 30; +const DOCUMENT_PREVIEW_DPI = 144; + +function getPageNumber(fileName: string) { + const match = fileName.match(/-(\d+)\.jpe?g$/i); + return match ? Number(match[1]) : 0; +} + +async function listCachedPreviewUrls(cacheKey: string, cacheDirectory: string) { + if (!existsSync(cacheDirectory)) { + return []; + } + + const fileNames = await readdir(cacheDirectory); + return fileNames + .filter((fileName) => /^page-\d+\.jpg$/i.test(fileName)) + .sort((left, right) => getPageNumber(left) - getPageNumber(right)) + .map((fileName) => buildNonIntegratedDocumentPreviewUrl(cacheKey, fileName)); +} + +async function convertPresentationToPdf(params: { + sourceFilePath: string; + extension: string; + tempDirectory: string; +}) { + const tempSourcePath = path.join(params.tempDirectory, `source${params.extension}`); + const tempOutputPath = path.join(params.tempDirectory, "source.pdf"); + + await copyFile(params.sourceFilePath, tempSourcePath); + await execFileAsync( + "soffice", + ["--headless", "--convert-to", "pdf", "--outdir", params.tempDirectory, tempSourcePath], + { + timeout: 120000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + + if (!existsSync(tempOutputPath)) { + throw new Error("The presentation could not be converted to PDF."); + } + + return tempOutputPath; +} + +async function convertPdfToJpgPreviews(params: { + pdfFilePath: string; + tempDirectory: string; + cacheDirectory: string; +}) { + const outputRoot = path.join(params.tempDirectory, "preview-page"); + + await execFileAsync( + "pdftoppm", + [ + "-jpeg", + "-r", + String(DOCUMENT_PREVIEW_DPI), + "-f", + "1", + "-l", + String(DOCUMENT_PREVIEW_MAX_PAGES), + params.pdfFilePath, + outputRoot, + ], + { + timeout: 120000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + + const generatedFileNames = (await readdir(params.tempDirectory)) + .filter((fileName) => /^preview-page-\d+\.jpe?g$/i.test(fileName)) + .sort((left, right) => getPageNumber(left) - getPageNumber(right)); + + if (generatedFileNames.length === 0) { + throw new Error("No document preview images were generated."); + } + + await mkdir(params.cacheDirectory, { recursive: true }); + + const cachedFileNames: string[] = []; + for (const [index, generatedFileName] of generatedFileNames.entries()) { + const cachedFileName = `page-${index + 1}.jpg`; + await copyFile( + path.join(params.tempDirectory, generatedFileName), + path.join(params.cacheDirectory, cachedFileName), + ); + cachedFileNames.push(cachedFileName); + } + + return cachedFileNames; +} + +export async function POST(request: NextRequest, context: RouteContext) { + return handleRequest( + request, + async ({ requestId, user }) => { + const { id } = await context.params; + const productId = decodeURIComponent(id); + const payload = (await request.json().catch(() => null)) as + | { fileId?: string; fileUrl?: string } + | null; + const fileId = typeof payload?.fileId === "string" ? payload.fileId.trim() : ""; + const fileUrl = typeof payload?.fileUrl === "string" ? payload.fileUrl.trim() : ""; + + if (!fileId && !fileUrl) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + "Please provide the document file to preview.", + 400, + requestId, + ); + } + + let selectedFileUrl = fileUrl; + + try { + const { file } = await intakeNonIntegratedProductService.resolveUploadFileForPreview( + productId, + { + ...(fileId ? { fileId } : {}), + ...(fileUrl ? { fileUrl } : {}), + }, + user, + ); + selectedFileUrl = file.dataUrl; + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, requestId); + } + + throw error; + } + + const sourceFilePath = resolveNonIntegratedAssetFilePath(selectedFileUrl); + + if (!sourceFilePath || !existsSync(sourceFilePath)) { + return jsonError(ErrorCode.FILE_NOT_FOUND, "The document file to preview was not found.", 404, requestId); + } + + const extension = path.extname(sourceFilePath).toLowerCase(); + if (!DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) { + return jsonError( + ErrorCode.FILE_TYPE_NOT_ALLOWED, + "Only PDF, PPT, or PPTX files can be converted to image previews.", + 400, + requestId, + ); + } + + const sourceStat = await stat(sourceFilePath); + const cacheKey = createHash("sha1") + .update(`${sourceFilePath}:${sourceStat.size}:${sourceStat.mtimeMs}:jpg-v1`) + .digest("hex"); + const cacheDirectory = path.join(DOCUMENT_PREVIEW_DIR, cacheKey); + const cachedUrls = await listCachedPreviewUrls(cacheKey, cacheDirectory); + + if (cachedUrls.length > 0) { + return jsonSuccess({ imageUrls: cachedUrls }, 200, requestId); + } + + let tempDirectory = ""; + + try { + tempDirectory = await mkdtemp(path.join(tmpdir(), "non-integrated-document-preview-")); + const pdfFilePath = PRESENTATION_EXTENSIONS.has(extension) + ? await convertPresentationToPdf({ + sourceFilePath, + extension, + tempDirectory, + }) + : sourceFilePath; + const cachedFileNames = await convertPdfToJpgPreviews({ + pdfFilePath, + tempDirectory, + cacheDirectory, + }); + const imageUrls = cachedFileNames.map((fileName) => + buildNonIntegratedDocumentPreviewUrl(cacheKey, fileName), + ); + + return jsonSuccess({ imageUrls }, 200, requestId); + } catch (error) { + await rm(cacheDirectory, { recursive: true, force: true }).catch(() => undefined); + + return jsonError( + ErrorCode.FILE_UPLOAD_FAILED, + error instanceof Error && error.message + ? `Document image preview generation failed: ${error.message}` + : "Document image preview generation failed.", + 500, + requestId, + ); + } finally { + if (tempDirectory) { + await rm(tempDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } + }, + { requireAuth: true }, + ); +} + 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 125bdf3c..d6052c27 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 @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import NonIntegratedDetailClient from "./NonIntegratedDetailClient"; import { + previewSharedNonIntegratedDocumentImages, saveSharedNonIntegratedProduct, uploadSharedNonIntegratedFile, } from "./nonIntegratedApi"; @@ -58,10 +59,14 @@ jest.mock("../../../lib/model-preview-zip", () => ({ })); jest.mock("./nonIntegratedApi", () => ({ + previewSharedNonIntegratedDocumentImages: jest.fn(), saveSharedNonIntegratedProduct: jest.fn(async (product) => product), uploadSharedNonIntegratedFile: jest.fn(), })); +const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked( + previewSharedNonIntegratedDocumentImages, +); const mockedSaveSharedNonIntegratedProduct = jest.mocked( saveSharedNonIntegratedProduct, ); @@ -190,6 +195,10 @@ describe("NonIntegratedDetailClient", () => { (parseZipFile as jest.Mock).mockReset(); (extractPreviewModelFromZip as jest.Mock).mockReset(); + mockedPreviewSharedNonIntegratedDocumentImages.mockReset(); + mockedPreviewSharedNonIntegratedDocumentImages.mockResolvedValue({ + imageUrls: ["/api/intake/non-integrated/assets/document-previews/test/page-1.jpg"], + }); mockedSaveSharedNonIntegratedProduct.mockReset(); mockedSaveSharedNonIntegratedProduct.mockImplementation(async (product) => product); mockedUploadSharedNonIntegratedFile.mockReset(); @@ -315,6 +324,58 @@ describe("NonIntegratedDetailClient", () => { expect(reviewTrigger).not.toHaveClass("bg-[#e6ebf0]"); }); + it("renders PDF files through generated JPG previews in the document workspace", async () => { + const actor = userEvent.setup(); + + mockedPreviewSharedNonIntegratedDocumentImages.mockResolvedValueOnce({ + imageUrls: [ + "/api/intake/non-integrated/assets/document-previews/cache/page-1.jpg", + ], + }); + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-pdf-preview", + uploadFiles: [ + { + id: "product-data-pdf-1", + name: "资料.pdf", + category: "产品模型", + dataUrl: "/api/intake/non-integrated/assets/product-data.pdf", + mimeType: "application/pdf", + }, + ], + }), + ]), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品模型")); + + await waitFor(() => + expect(mockedPreviewSharedNonIntegratedDocumentImages).toHaveBeenCalledWith({ + productId: "DNZ0001-pdf-preview", + fileId: "product-data-pdf-1", + fileUrl: "/api/intake/non-integrated/assets/product-data.pdf", + }), + ); + + const previewImage = await screen.findByTestId( + "document-workspace-jpg-preview", + ); + expect(previewImage).toHaveAttribute( + "src", + "/api/intake/non-integrated/assets/document-previews/cache/page-1.jpg", + ); + expect( + screen.queryByTestId("document-workspace-pdf-preview"), + ).not.toBeInTheDocument(); + }); + it("uses the page header return action to go back from document workspace to detail view", async () => { const actor = userEvent.setup(); @@ -413,6 +474,141 @@ describe("NonIntegratedDetailClient", () => { ).not.toBeInTheDocument(); }); + it("counts review data as zero until the first round has a filled issue", () => { + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-empty-review-count", + spu: "SZ0230", + }), + ]), + ); + + const emptyRender = render( + , + ); + + expect( + within(screen.getByTestId("document-workspace-trigger-评审资料")).getByText( + "0", + ), + ).toBeInTheDocument(); + + emptyRender.unmount(); + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-filled-review-count", + spu: "SZ0230", + reviewSheet: { + ...createProductRecord().reviewSheet, + issues: [ + { + id: "review-issue-filled", + issueImage: "", + issueImageName: "", + issueDescription: "导轨问题", + fixSuggestion: "", + referenceImage: "", + referenceImageName: "", + }, + { + id: "review-issue-empty", + issueImage: "", + issueImageName: "", + issueDescription: "", + fixSuggestion: "", + referenceImage: "", + referenceImageName: "", + }, + ], + }, + }), + ]), + ); + + render( + , + ); + + expect( + within(screen.getByTestId("document-workspace-trigger-评审资料")).getByText( + "1", + ), + ).toBeInTheDocument(); + }); + + it("asks for confirmation before adding a review round", async () => { + const actor = userEvent.setup(); + const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-review-round-confirm", + spu: "SZ0230", + reviewSheet: { + ...createProductRecord().reviewSheet, + issues: [ + { + id: "review-issue-filled", + issueImage: "", + issueImageName: "", + issueDescription: "导轨问题", + fixSuggestion: "", + referenceImage: "", + referenceImageName: "", + }, + ], + }, + }), + ]), + ); + + try { + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-评审资料")); + await actor.click(screen.getByRole("button", { name: "新增轮次" })); + + expect(confirmSpy).toHaveBeenCalledWith( + expect.stringContaining("真的确认吗"), + ); + expect(screen.queryByText("第2次评审")).not.toBeInTheDocument(); + expect( + within( + screen.getByTestId("document-workspace-trigger-评审资料"), + ).getByText("1"), + ).toBeInTheDocument(); + + confirmSpy.mockReturnValue(true); + await actor.click(screen.getByRole("button", { name: "新增轮次" })); + + expect(screen.getByText("第2次评审")).toBeInTheDocument(); + expect( + within( + screen.getByTestId("document-workspace-trigger-评审资料"), + ).getByText("2"), + ).toBeInTheDocument(); + } finally { + confirmSpy.mockRestore(); + } + }); + it("locks copied products to a single brand and shows a read-only adaptable brand field", () => { window.localStorage.setItem( NON_INTEGRATED_STORAGE_KEY, @@ -564,6 +760,171 @@ describe("NonIntegratedDetailClient", () => { within(detailList).getByAltText("扶手细节图.png"), ).toBeInTheDocument(); }); + + it("uploads directly when clicking an empty nine-view slot", async () => { + const actor = userEvent.setup(); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-product-photo-slot-upload", + }), + ]), + ); + + render( + , + ); + + const productPhotoTrigger = screen + .getByTestId("document-workspace-sidebar") + .querySelector( + 'button[data-testid^="document-workspace-trigger-"]', + ); + expect(productPhotoTrigger).not.toBeNull(); + await actor.click(productPhotoTrigger!); + await actor.click(screen.getByTestId("product-photo-slot-back")); + await actor.upload( + screen.getByTestId("product-photo-current-upload-input"), + new File(["back"], "custom-back.png", { type: "image/png" }), + ); + + expect( + await screen.findByTestId("product-photo-slot-image-back"), + ).toHaveAttribute("alt", "custom-back.png"); + }); + + it("uploads grouped product photos from the empty detail panel", async () => { + const actor = userEvent.setup(); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-product-photo-empty-detail-upload", + }), + ]), + ); + + render( + , + ); + + const productPhotoTrigger = screen + .getByTestId("document-workspace-sidebar") + .querySelector( + 'button[data-testid^="document-workspace-trigger-"]', + ); + expect(productPhotoTrigger).not.toBeNull(); + await actor.click(productPhotoTrigger!); + await actor.click(screen.getByTestId("product-photo-panel-tab-detail")); + await actor.click(screen.getByTestId("product-photo-empty-panel-upload-detail")); + await actor.upload( + screen.getByTestId("product-photo-detail-upload-input"), + new File(["detail"], "detail-click-upload.png", { type: "image/png" }), + ); + + const detailList = await screen.findByTestId("product-photo-detail-list"); + expect( + within(detailList).getByText("detail-click-upload.png"), + ).toBeInTheDocument(); + }); + + it("hides product photo styles that are not present in sku models", async () => { + const actor = userEvent.setup(); + const createStyleDetail = (styleKey: string) => ({ + styleKey, + installMethod: "", + installSteps: "", + packageDetail: "", + craftDetail: "", + model3dFileName: "", + model3dDataUrl: "", + }); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-product-photo-sku-styles", + skuItems: [createSkuItem({ model: "SZ0230K01" })], + styleDetails: ["K01", "K02", "K03"].map(createStyleDetail), + }), + ]), + ); + + render( + , + ); + + const productPhotoTrigger = screen + .getByTestId("document-workspace-sidebar") + .querySelector( + 'button[data-testid^="document-workspace-trigger-"]', + ); + expect(productPhotoTrigger).not.toBeNull(); + await actor.click(productPhotoTrigger!); + + expect(screen.getByTestId("product-photo-style-K01")).toBeInTheDocument(); + expect(screen.queryByTestId("product-photo-style-K02")).not.toBeInTheDocument(); + expect(screen.queryByTestId("product-photo-style-K03")).not.toBeInTheDocument(); + }); + + it("keeps the nine-view tab selectable when only detail photos exist", async () => { + const actor = userEvent.setup(); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-nine-view-empty", + spu: "SZ0230", + skuItems: [createSkuItem()], + uploadFiles: [ + { + id: "detail-only-photo", + name: "detail-only.png", + category: "产品实拍图", + dataUrl: "data:image/png;base64,detail-only", + mimeType: "image/png", + photoGroup: "detail", + productPhotoStyleKey: "K01", + }, + ], + }), + ]), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图")); + await actor.click(screen.getByTestId("product-photo-panel-tab-detail")); + + expect(await screen.findByTestId("product-photo-detail-list")).toBeInTheDocument(); + + await actor.click(screen.getByTestId("product-photo-panel-tab-nine-view")); + + expect(await screen.findByTestId("product-photo-nine-view-grid")).toBeInTheDocument(); + expect(screen.getByTestId("product-photo-panel-tab-nine-view")).toHaveClass( + "bg-apple-label-primary", + ); + }); + it("persists 泡沫护角 rows after submit and reload", async () => { const actor = userEvent.setup(); diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index ead7963a..36014b09 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -119,7 +119,7 @@ import { type NonIntegratedUploadFile, } from "./nonIntegratedStorage"; import { - previewSharedNonIntegratedPresentation, + previewSharedNonIntegratedDocumentImages, saveSharedNonIntegratedProduct, uploadDetachedNonIntegratedFile, uploadSharedNonIntegratedFile, @@ -1383,6 +1383,23 @@ function getInstallationStyleLabel(styleKey: string) { return toChineseStyleLabel(styleKey) || styleKey || "安装资料"; } +function getProductPhotoStyleLabel( + styleKey: string, + skuItems: NonIntegratedSkuItem[], +) { + const normalizedStyleKey = styleKey.trim().toUpperCase(); + const matchedSku = skuItems.find( + (item) => getStyleKeyFromModel(item.model) === normalizedStyleKey, + ); + const modelLabel = matchedSku?.model.trim(); + + return ( + modelLabel || + getInstallationStyleLabel(normalizedStyleKey) || + "产品实拍" + ); +} + function getDefaultProductPhotoStyleKey(knownStyleKeys: string[]) { const normalizedStyleKeys = sortStyleKeys( Array.from( @@ -4578,6 +4595,10 @@ export default function NonIntegratedDetailClient({ const productPhotoCurrentUploadInputRef = useRef( null, ); + const pendingProductPhotoPanelUploadRef = + useRef(null); + const pendingProductPhotoCurrentUploadTargetRef = + useRef(null); const model3dInputRef = useRef(null); const existingProduct = mode !== "create" && productId @@ -4671,10 +4692,14 @@ export default function NonIntegratedDetailClient({ ] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [workspaceStructuredPreviewError, setWorkspaceStructuredPreviewError] = useState(""); - const [workspacePresentationPdfUrl, setWorkspacePresentationPdfUrl] = - useState(""); - const [workspacePresentationPreviewUrl, setWorkspacePresentationPreviewUrl] = - useState(""); + const [ + workspaceDocumentPreviewImageUrls, + setWorkspaceDocumentPreviewImageUrls, + ] = useState([]); + const [ + activeWorkspaceDocumentPreviewImageIndex, + setActiveWorkspaceDocumentPreviewImageIndex, + ] = useState(0); const [activeSpreadsheetSheetIndex, setActiveSpreadsheetSheetIndex] = useState(0); const [selectedSupplementSkuId, setSelectedSupplementSkuId] = useState(""); @@ -5172,9 +5197,16 @@ export default function NonIntegratedDetailClient({ [selectedBomRowsByCategory], ); const populatedReviewRoundCount = useMemo( - () => - reviewRounds.filter((round) => hasReviewSheetContent(round.reviewSheet)) - .length, + () => { + if (reviewRounds.length === 0) return 0; + if (reviewRounds.length > 1) return reviewRounds.length; + + return reviewRounds[0].reviewSheet.issues.some((issue) => + hasReviewIssueContent(issue), + ) + ? 1 + : 0; + }, [reviewRounds], ); const qualityInspectionResolvedRows = useMemo( @@ -5376,16 +5408,9 @@ export default function NonIntegratedDetailClient({ return sortStyleKeys(Array.from(nextKeys)); }, [derivedSkuItems, styleDetails, uploadFiles]); - const productPhotoKnownStyleKeys = useMemo(() => { + const productPhotoSkuStyleKeys = useMemo(() => { const nextKeys = new Set(); - Object.keys(styleDetails).forEach((styleKey) => { - const normalizedStyleKey = styleKey.trim().toUpperCase(); - if (normalizedStyleKey) { - nextKeys.add(normalizedStyleKey); - } - }); - derivedSkuItems.forEach((item) => { const styleKey = getStyleKeyFromModel(item.model); if (styleKey) { @@ -5393,7 +5418,18 @@ export default function NonIntegratedDetailClient({ } }); + return sortStyleKeys(Array.from(nextKeys)); + }, [derivedSkuItems]); + const productPhotoKnownStyleKeys = useMemo(() => { + if (productPhotoSkuStyleKeys.length > 0) { + return productPhotoSkuStyleKeys; + } + + const nextKeys = new Set(); + uploadFiles.forEach((file) => { + if (file.category !== PRODUCT_PHOTO_CATEGORY) return; + const styleKey = file.productPhotoStyleKey?.trim().toUpperCase(); if (styleKey) { nextKeys.add(styleKey); @@ -5402,7 +5438,7 @@ export default function NonIntegratedDetailClient({ const sortedStyleKeys = sortStyleKeys(Array.from(nextKeys)); return sortedStyleKeys.length > 0 ? sortedStyleKeys : ["K01"]; - }, [derivedSkuItems, styleDetails, uploadFiles]); + }, [productPhotoSkuStyleKeys, uploadFiles]); const defaultProductPhotoStyleKey = useMemo( () => getDefaultProductPhotoStyleKey(productPhotoKnownStyleKeys), [productPhotoKnownStyleKeys], @@ -5562,7 +5598,7 @@ export default function NonIntegratedDetailClient({ const nextEntry: ProductPhotoStyleEntry = { key: normalizedStyleKey, - label: getInstallationStyleLabel(normalizedStyleKey), + label: getProductPhotoStyleLabel(normalizedStyleKey, derivedSkuItems), files: [], nineViewCount: 0, groupedCount: 0, @@ -5599,7 +5635,7 @@ export default function NonIntegratedDetailClient({ .sort((left, right) => sortStyleKeys([left.key, right.key])[0] === left.key ? -1 : 1, ); - }, [activeDocumentWorkspaceFiles, productPhotoKnownStyleKeys]); + }, [activeDocumentWorkspaceFiles, derivedSkuItems, productPhotoKnownStyleKeys]); const activeProductPhotoStyleEntry = productPhotoStyleEntries.find( (entry) => entry.key === selectedProductPhotoStyleKey, @@ -5792,7 +5828,7 @@ export default function NonIntegratedDetailClient({ activeProductPhotoStyleEntry?.key ?? defaultProductPhotoStyleKey; const currentProductPhotoStyleLabel = activeProductPhotoStyleEntry?.label ?? - getInstallationStyleLabel(defaultProductPhotoStyleKey); + getProductPhotoStyleLabel(defaultProductPhotoStyleKey, derivedSkuItems); const documentWorkspaceTitle = `${getDocumentWorkspaceCategoryLabel(activeDocumentWorkspaceCategory)}工作区`; const documentWorkspaceDescription = isProductPhotoWorkspace ? `按款式分栏维护产品实拍。当前款式:${currentProductPhotoStyleLabel};九视图支持按文件名自动匹配,其他照片分组支持批量上传和替换。` @@ -6477,7 +6513,9 @@ export default function NonIntegratedDetailClient({ fileId: "", }; } else if ( - productPhotoWorkspaceData.slotFiles[selectedProductPhotoTarget.slot] + PRODUCT_PHOTO_NINE_VIEW_SLOTS.some( + (slot) => slot.key === selectedProductPhotoTarget.slot, + ) ) { nextTarget = selectedProductPhotoTarget; } else if (firstFilledSlot) { @@ -6516,19 +6554,15 @@ export default function NonIntegratedDetailClient({ setActiveSpreadsheetSheetIndex(0); setWorkspaceStructuredPreview(null); setWorkspaceStructuredPreviewError(""); - setWorkspacePresentationPdfUrl(""); - setWorkspacePresentationPreviewUrl((previousUrl) => { - if (previousUrl) { - URL.revokeObjectURL(previousUrl); - } - return ""; - }); + setWorkspaceDocumentPreviewImageUrls([]); + setActiveWorkspaceDocumentPreviewImageIndex(0); if ( (isDetectionWorkspace && activeDetectionWorkspacePanel === "quality-standard") || !effectiveSelectedWorkspaceFile || (effectiveSelectedWorkspacePreviewType !== "spreadsheet" && + effectiveSelectedWorkspacePreviewType !== "pdf" && effectiveSelectedWorkspacePreviewType !== "presentation") ) { setWorkspaceStructuredPreviewStatus("idle"); @@ -6540,7 +6574,10 @@ export default function NonIntegratedDetailClient({ void (async () => { try { - if (effectiveSelectedWorkspacePreviewType === "presentation") { + if ( + effectiveSelectedWorkspacePreviewType === "pdf" || + effectiveSelectedWorkspacePreviewType === "presentation" + ) { const normalizedFileSource = effectiveSelectedWorkspaceFile.dataUrl.trim(); @@ -6550,9 +6587,9 @@ export default function NonIntegratedDetailClient({ ) { try { if (!sharedUploadProductId) { - throw new Error("当前 PPT 暂时无法生成原样预览。"); + throw new Error("当前文件暂时无法生成图片预览。"); } - const payload = await previewSharedNonIntegratedPresentation({ + const payload = await previewSharedNonIntegratedDocumentImages({ productId: sharedUploadProductId, fileId: effectiveSelectedWorkspaceFile.id, fileUrl: normalizedFileSource, @@ -6560,16 +6597,28 @@ export default function NonIntegratedDetailClient({ if (isCancelled) return; - setWorkspacePresentationPdfUrl(payload.pdfUrl); + if (payload.imageUrls.length === 0) { + throw new Error("当前文件没有生成可预览图片。"); + } + + setWorkspaceDocumentPreviewImageUrls(payload.imageUrls); setWorkspaceStructuredPreviewStatus("ready"); return; - } catch { - // Server-side conversion is preferred for fidelity. + } catch (error) { + if (effectiveSelectedWorkspacePreviewType === "pdf") { + throw error; + } + // Server-side image conversion is preferred for fidelity. // Fall back to the existing client-side parser when conversion is unavailable. } } } + if (effectiveSelectedWorkspacePreviewType === "pdf") { + setWorkspaceStructuredPreviewStatus("ready"); + return; + } + const previewModule = await import("@/lib/document-workspace-preview"); const nextPreview = effectiveSelectedWorkspacePreviewType === "spreadsheet" @@ -6610,66 +6659,9 @@ export default function NonIntegratedDetailClient({ effectiveSelectedWorkspaceFile?.name, effectiveSelectedWorkspacePreviewType, isDetectionWorkspace, + sharedUploadProductId, ]); - useEffect(() => { - if (!workspacePresentationPdfUrl) { - setWorkspacePresentationPreviewUrl((previousUrl) => { - if (previousUrl) { - URL.revokeObjectURL(previousUrl); - } - return ""; - }); - return; - } - - let isCancelled = false; - let nextObjectUrl = ""; - - void (async () => { - try { - const response = await fetch(workspacePresentationPdfUrl, { - credentials: "include", - }); - - if (!response.ok) { - throw new Error("当前 PPT 预览文件暂时无法读取。"); - } - - const pdfBlob = await response.blob(); - nextObjectUrl = URL.createObjectURL(pdfBlob); - - if (isCancelled) { - URL.revokeObjectURL(nextObjectUrl); - return; - } - - setWorkspacePresentationPreviewUrl((previousUrl) => { - if (previousUrl) { - URL.revokeObjectURL(previousUrl); - } - return nextObjectUrl; - }); - } catch { - if (isCancelled) return; - - setWorkspacePresentationPreviewUrl((previousUrl) => { - if (previousUrl) { - URL.revokeObjectURL(previousUrl); - } - return ""; - }); - } - })(); - - return () => { - isCancelled = true; - if (nextObjectUrl) { - URL.revokeObjectURL(nextObjectUrl); - } - }; - }, [workspacePresentationPdfUrl]); - useEffect(() => { if (!effectiveModel3dDataUrl) { setModel3dPreviewUrl(""); @@ -7655,6 +7647,28 @@ export default function NonIntegratedDetailClient({ setSelectedProductPhotoStyleKey(styleKey); } + function openProductPhotoPanelUpload(panel: ProductPhotoGroup) { + if (isReadOnly) return; + + pendingProductPhotoPanelUploadRef.current = panel; + if (panel === "nine-view") { + handleSwitchProductPhotoPanel("nine-view"); + } else { + const nextGroupFileId = + productPhotoWorkspaceData.groupFiles[panel][0]?.id ?? ""; + handleSelectProductPhotoGroupedFile(panel, nextGroupFileId); + } + productPhotoDetailUploadInputRef.current?.click(); + } + + function openProductPhotoCurrentUpload(target: ProductPhotoSelection) { + if (isReadOnly) return; + + pendingProductPhotoCurrentUploadTargetRef.current = target; + setSelectedProductPhotoTarget(target); + productPhotoCurrentUploadInputRef.current?.click(); + } + function handleSelectProductPhotoSlot(slot: ProductPhotoNineViewSlot) { setSelectedProductPhotoTarget({ group: "nine-view", @@ -8169,19 +8183,26 @@ export default function NonIntegratedDetailClient({ if (isReadOnly) return; const files = Array.from(event.target.files ?? []); - if (files.length === 0) return; + const uploadPanel = + pendingProductPhotoPanelUploadRef.current ?? activeProductPhotoPanel; + pendingProductPhotoPanelUploadRef.current = null; + if (files.length === 0) { + event.target.value = ""; + return; + } try { - if (activeProductPhotoPanel === "nine-view") { + if (uploadPanel === "nine-view") { await handleBatchUploadProductPhotoNineViews(files); } else { await handleUploadProductPhotoGroupedFiles( files, - activeProductPhotoPanel, + uploadPanel, ); } } finally { event.target.value = ""; + pendingProductPhotoPanelUploadRef.current = null; } } @@ -8190,12 +8211,16 @@ export default function NonIntegratedDetailClient({ ) { if (isReadOnly) return; + const uploadTarget = + pendingProductPhotoCurrentUploadTargetRef.current ?? + selectedProductPhotoTarget; + pendingProductPhotoCurrentUploadTargetRef.current = null; const file = event.target.files?.[0]; if (!file) return; try { - if (selectedProductPhotoTarget.group === "nine-view") { - const currentSlot = selectedProductPhotoTarget.slot; + if (uploadTarget.group === "nine-view") { + const currentSlot = uploadTarget.slot; const currentSlotFile = productPhotoWorkspaceData.slotFiles[currentSlot]; const nextFile = await createUploadFileRecord( @@ -8245,8 +8270,8 @@ export default function NonIntegratedDetailClient({ } else { const currentGroupedFile = productPhotoWorkspaceData.groupFiles[ - selectedProductPhotoTarget.group - ].find((item) => item.id === selectedProductPhotoTarget.fileId) ?? + uploadTarget.group + ].find((item) => item.id === uploadTarget.fileId) ?? null; if (!currentGroupedFile) { event.target.value = ""; @@ -8259,7 +8284,7 @@ export default function NonIntegratedDetailClient({ { productId: sharedUploadProductId, fileId: currentGroupedFile.id, - photoGroup: selectedProductPhotoTarget.group, + photoGroup: uploadTarget.group, productPhotoStyleKey: currentProductPhotoStyleKey, }, ); @@ -8275,7 +8300,7 @@ export default function NonIntegratedDetailClient({ ), ); setSelectedProductPhotoTarget({ - group: selectedProductPhotoTarget.group, + group: uploadTarget.group, fileId: currentGroupedFile.id, }); } @@ -8287,8 +8312,10 @@ export default function NonIntegratedDetailClient({ error instanceof Error ? error.message : "当前图片替换失败", ); setSaveMessageType("error"); + } finally { + pendingProductPhotoCurrentUploadTargetRef.current = null; + event.target.value = ""; } - event.target.value = ""; } function handleDownloadUploadFile(file: NonIntegratedUploadFile) { @@ -8741,6 +8768,13 @@ export default function NonIntegratedDetailClient({ function handleAddReviewRound() { if (isReadOnly) return; + if ( + !window.confirm( + "确定新增轮次吗?\n点击确认后会新增一轮空白评审;真的确认吗?", + ) + ) { + return; + } const nextRoundNumber = reviewRounds.length + 1; const nextRound = createReviewRound(nextRoundNumber, { @@ -8998,6 +9032,77 @@ export default function NonIntegratedDetailClient({ setWorkspaceImagePreview({ kind: "workspace-gallery" }); } + function renderDocumentImagePreviewFromGeneratedJpgs(fileName: string) { + const safeActiveIndex = Math.min( + activeWorkspaceDocumentPreviewImageIndex, + Math.max(workspaceDocumentPreviewImageUrls.length - 1, 0), + ); + const activeImageUrl = workspaceDocumentPreviewImageUrls[safeActiveIndex]; + + if (!activeImageUrl) { + return null; + } + + const canGoPrevious = safeActiveIndex > 0; + const canGoNext = + safeActiveIndex < workspaceDocumentPreviewImageUrls.length - 1; + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${fileName} +
+ + {workspaceDocumentPreviewImageUrls.length > 1 ? ( +
+ + + 第 {safeActiveIndex + 1} 页 / 共{" "} + {workspaceDocumentPreviewImageUrls.length} 页 + + +
+ ) : null} +
+ ); + } + function renderDocumentWorkspacePreviewContent() { if (!effectiveSelectedWorkspaceFile) { return ( @@ -9077,6 +9182,38 @@ export default function NonIntegratedDetailClient({ } if (effectiveSelectedWorkspacePreviewType === "pdf") { + if (workspaceStructuredPreviewStatus === "loading") { + return ( + + ); + } + + if (workspaceStructuredPreviewStatus === "error") { + return ( + + ); + } + + const generatedPreview = + renderDocumentImagePreviewFromGeneratedJpgs( + effectiveSelectedWorkspaceFile.name, + ); + + if (generatedPreview) { + return generatedPreview; + } + return (