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 */}
+

+
+
+ {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 (
);
}
@@ -9165,25 +9302,13 @@ export default function NonIntegratedDetailClient({
);
}
- if (workspacePresentationPdfUrl && !workspacePresentationPreviewUrl) {
- return (
-
+ const generatedPreview =
+ renderDocumentImagePreviewFromGeneratedJpgs(
+ effectiveSelectedWorkspaceFile.name,
);
- }
- if (workspacePresentationPreviewUrl) {
- return (
-
- );
+ if (generatedPreview) {
+ return generatedPreview;
}
if (workspaceStructuredPreview?.kind === "presentation") {
@@ -10821,13 +10946,6 @@ export default function NonIntegratedDetailClient({
{pageTitle}
-
-
-
-
-
{saveMessage ? (
@@ -12339,7 +12457,9 @@ export default function NonIntegratedDetailClient({
- 按款式分开上传查看,未标款式默认归到款式一。
+ 按型号分开上传查看,未标型号默认归到第一个型号。
@@ -12980,11 +13102,24 @@ export default function NonIntegratedDetailClient({
key={slot.key}
type="button"
data-testid={`product-photo-slot-${slot.key}`}
- onClick={() =>
+ onClick={() => {
+ const nextTarget: ProductPhotoSelection =
+ {
+ group: "nine-view",
+ slot: slot.key,
+ };
+
+ if (!slotFile && !isReadOnly) {
+ openProductPhotoCurrentUpload(
+ nextTarget,
+ );
+ return;
+ }
+
handleSelectProductPhotoSlot(
slot.key,
- )
- }
+ );
+ }}
className={`grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] rounded-apple-lg border p-1.5 text-left transition ${
isActive
? "border-apple-blue bg-apple-blue/10"
@@ -13026,16 +13161,26 @@ export default function NonIntegratedDetailClient({
) : (
{activeProductPhotoPanelFiles.length === 0 ? (
-
+
+
) : (
-
+
图片与文档
- {isDocumentWorkspaceMode ? (
-
- ) : null}
+
+ {!isDocumentWorkspaceMode ? (
+
+
+
+ ) : null}
+ {isDocumentWorkspaceMode ? (
+
+ ) : null}
+
右侧按分类切换工作区
diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts
index ed6e3a16..4d92f95a 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts
@@ -139,3 +139,22 @@ export async function previewSharedNonIntegratedPresentation(params: {
return readApiData<{ pdfUrl: string }>(response)
}
+
+export async function previewSharedNonIntegratedDocumentImages(params: {
+ productId: string
+ fileId?: string
+ fileUrl?: string
+}) {
+ const response = await fetch(
+ `/api/intake/non-integrated/${encodeURIComponent(params.productId)}/document-preview`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(params),
+ },
+ )
+
+ return readApiData<{ imageUrls: string[] }>(response)
+}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetServer.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetServer.ts
index 1b046e98..61ee1e1c 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetServer.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetServer.ts
@@ -1,6 +1,7 @@
import path from "path";
import {
NON_INTEGRATED_ASSET_DIRECTORY_NAME,
+ NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY,
NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY,
buildNonIntegratedAssetUrl,
extractNonIntegratedAssetRelativePath,
@@ -36,3 +37,11 @@ export function buildNonIntegratedPresentationPreviewUrl(fileName: string) {
);
}
+export function buildNonIntegratedDocumentPreviewUrl(
+ cacheDirectoryName: string,
+ fileName: string,
+) {
+ return buildNonIntegratedAssetUrl(
+ `${NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY}/${cacheDirectoryName}/${fileName}`,
+ );
+}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetUrl.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetUrl.ts
index 186c3f58..73b19f7b 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetUrl.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedAssetUrl.ts
@@ -6,6 +6,8 @@ export const NON_INTEGRATED_ASSET_DIRECTORY_NAME =
"non-integrated-test-assets";
export const NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY =
"presentation-previews";
+export const NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY =
+ "document-previews";
function stripHashAndQuery(value: string) {
return value.split("#", 1)[0]?.split("?", 1)[0] ?? "";
@@ -65,4 +67,3 @@ export function normalizeNonIntegratedAssetUrl(value: string) {
return buildNonIntegratedAssetUrl(relativePath);
}
-