fix(intake): refine product photo uploads and document previews

This commit is contained in:
WuYongJie 2026-04-23 13:30:40 +08:00
parent cf82b30096
commit e263b24f5a
6 changed files with 939 additions and 148 deletions

View File

@ -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 },
);
}

View File

@ -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(
<NonIntegratedDetailClient mode="edit" productId="DNZ0001-pdf-preview" />,
);
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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-empty-review-count"
/>,
);
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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-filled-review-count"
/>,
);
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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-review-round-confirm"
/>,
);
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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-product-photo-slot-upload"
/>,
);
const productPhotoTrigger = screen
.getByTestId("document-workspace-sidebar")
.querySelector<HTMLButtonElement>(
'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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-product-photo-empty-detail-upload"
/>,
);
const productPhotoTrigger = screen
.getByTestId("document-workspace-sidebar")
.querySelector<HTMLButtonElement>(
'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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-product-photo-sku-styles"
/>,
);
const productPhotoTrigger = screen
.getByTestId("document-workspace-sidebar")
.querySelector<HTMLButtonElement>(
'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(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-nine-view-empty"
/>,
);
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();

View File

@ -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<HTMLInputElement | null>(
null,
);
const pendingProductPhotoPanelUploadRef =
useRef<ProductPhotoGroup | null>(null);
const pendingProductPhotoCurrentUploadTargetRef =
useRef<ProductPhotoSelection | null>(null);
const model3dInputRef = useRef<HTMLInputElement | null>(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<string[]>([]);
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<string>();
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<string>();
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 (
<div className="flex h-full min-h-0 w-full flex-col gap-3">
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-white px-4 py-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
data-testid="document-workspace-jpg-preview"
src={activeImageUrl}
alt={`${fileName} 第 ${safeActiveIndex + 1} 页预览`}
className="max-h-full max-w-full object-contain"
/>
</div>
{workspaceDocumentPreviewImageUrls.length > 1 ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
variant="plain"
size="small"
onClick={() =>
setActiveWorkspaceDocumentPreviewImageIndex((current) =>
Math.max(0, current - 1),
)
}
disabled={!canGoPrevious}
>
上一页
</Button>
<span
data-testid="document-workspace-jpg-page-index"
className="rounded-full bg-apple-bg-secondary px-3 py-1 text-xs text-apple-label-secondary"
>
第 {safeActiveIndex + 1} 页 / 共{" "}
{workspaceDocumentPreviewImageUrls.length} 页
</span>
<Button
type="button"
variant="plain"
size="small"
onClick={() =>
setActiveWorkspaceDocumentPreviewImageIndex((current) =>
Math.min(
workspaceDocumentPreviewImageUrls.length - 1,
current + 1,
),
)
}
disabled={!canGoNext}
>
下一页
</Button>
</div>
) : null}
</div>
);
}
function renderDocumentWorkspacePreviewContent() {
if (!effectiveSelectedWorkspaceFile) {
return (
@ -9077,6 +9182,38 @@ export default function NonIntegratedDetailClient({
}
if (effectiveSelectedWorkspacePreviewType === "pdf") {
if (workspaceStructuredPreviewStatus === "loading") {
return (
<WorkspacePreviewEmptyState
testId="document-workspace-preview-loading"
title="正在生成图片预览"
description="请稍等,当前正在把 PDF 转成 JPG 预览图。"
/>
);
}
if (workspaceStructuredPreviewStatus === "error") {
return (
<WorkspacePreviewEmptyState
testId="document-workspace-preview-error"
title="PDF 图片预览暂不可用"
description={
workspaceStructuredPreviewError ||
"当前 PDF 暂时无法生成 JPG 预览。"
}
/>
);
}
const generatedPreview =
renderDocumentImagePreviewFromGeneratedJpgs(
effectiveSelectedWorkspaceFile.name,
);
if (generatedPreview) {
return generatedPreview;
}
return (
<iframe
title={`${effectiveSelectedWorkspaceFile.name} 预览`}
@ -9147,7 +9284,7 @@ export default function NonIntegratedDetailClient({
<WorkspacePreviewEmptyState
testId="document-workspace-preview-loading"
title="正在生成演示文稿预览"
description="请稍等,当前正在生成更接近原稿的 PPT 预览。"
description="请稍等,当前正在把 PPT 转成 JPG 预览图。"
/>
);
}
@ -9165,25 +9302,13 @@ export default function NonIntegratedDetailClient({
);
}
if (workspacePresentationPdfUrl && !workspacePresentationPreviewUrl) {
return (
<WorkspacePreviewEmptyState
testId="document-workspace-preview-loading"
title="正在载入原样预览"
description="请稍等,当前正在准备更接近原稿的 PPT 预览。"
/>
const generatedPreview =
renderDocumentImagePreviewFromGeneratedJpgs(
effectiveSelectedWorkspaceFile.name,
);
}
if (workspacePresentationPreviewUrl) {
return (
<iframe
title={`${effectiveSelectedWorkspaceFile.name} 原样预览`}
data-testid="document-workspace-presentation-pdf-preview"
src={workspacePresentationPreviewUrl}
className="h-full min-h-0 w-full rounded-apple-lg border border-apple-separator bg-white"
/>
);
if (generatedPreview) {
return generatedPreview;
}
if (workspaceStructuredPreview?.kind === "presentation") {
@ -10821,13 +10946,6 @@ export default function NonIntegratedDetailClient({
<h2 className="text-[30px] font-apple-semibold text-apple-label-primary">
{pageTitle}
</h2>
<div className="flex items-center gap-2">
<Link href="/intake">
<Button variant="plain" size="small">
返回列表
</Button>
</Link>
</div>
</div>
{saveMessage ? (
@ -12339,7 +12457,9 @@ export default function NonIntegratedDetailClient({
<Button
size="small"
onClick={() =>
productPhotoDetailUploadInputRef.current?.click()
openProductPhotoPanelUpload(
activeProductPhotoPanel,
)
}
>
{currentProductPhotoGroupUploadLabel}
@ -12360,7 +12480,9 @@ export default function NonIntegratedDetailClient({
variant="plain"
size="small"
onClick={() =>
productPhotoCurrentUploadInputRef.current?.click()
openProductPhotoCurrentUpload(
selectedProductPhotoTarget,
)
}
disabled={isCurrentProductPhotoUploadDisabled}
>
@ -12847,7 +12969,7 @@ export default function NonIntegratedDetailClient({
款式分类
</p>
<p className="mt-1 text-[11px] leading-4 text-apple-label-secondary">
按款式分开上传查看,未标款式默认归到款式一。
按型号分开上传查看,未标型号默认归到第一个型号。
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">
@ -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({
) : (
<div className="flex h-full min-h-0 flex-col">
{activeProductPhotoPanelFiles.length === 0 ? (
<div className="flex min-h-[180px] flex-1 flex-col items-center justify-center rounded-apple-xl border border-dashed border-apple-separator px-5 text-center">
<button
type="button"
data-testid={`product-photo-empty-panel-upload-${activeProductPhotoPanel}`}
onClick={() =>
openProductPhotoPanelUpload(
activeProductPhotoPanel,
)
}
disabled={isReadOnly}
className="flex min-h-[180px] flex-1 flex-col items-center justify-center rounded-apple-xl border border-dashed border-apple-separator px-5 text-center transition hover:border-apple-blue/50 hover:bg-apple-bg-secondary/40 disabled:cursor-default disabled:hover:border-apple-separator disabled:hover:bg-transparent"
>
<p className="text-sm font-apple-medium text-apple-label-primary">
当前还没有{activeProductPhotoPanelTitle}
</p>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
点击上方“
点击这里或上方“
{currentProductPhotoGroupUploadLabel}
”后,这里会显示图片缩略图列表。
</p>
</div>
</button>
) : (
<div
data-testid="product-photo-detail-list"
@ -13147,19 +13292,32 @@ export default function NonIntegratedDetailClient({
>
<div className="border-b border-apple-separator px-2.5 py-2.5">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-apple-semibold text-apple-label-primary">
<p className="min-w-0 truncate text-sm font-apple-semibold text-apple-label-primary">
图片与文档
</p>
{isDocumentWorkspaceMode ? (
<Button
variant="plain"
size="small"
className="!min-h-7 !rounded-apple-md !px-2.5 !py-1 !text-xs"
onClick={() => setDocumentWorkspaceView("detail")}
>
返回详情
</Button>
) : null}
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{!isDocumentWorkspaceMode ? (
<Link href="/intake">
<Button
variant="plain"
size="small"
className="!min-h-7 !rounded-apple-md !px-2.5 !py-1 !text-xs"
>
返回列表
</Button>
</Link>
) : null}
{isDocumentWorkspaceMode ? (
<Button
variant="plain"
size="small"
className="!min-h-7 !rounded-apple-md !px-2.5 !py-1 !text-xs"
onClick={() => setDocumentWorkspaceView("detail")}
>
返回详情
</Button>
) : null}
</div>
</div>
<p className="mt-1 text-[11px] text-apple-label-secondary">
右侧按分类切换工作区

View File

@ -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)
}

View File

@ -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}`,
);
}

View File

@ -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);
}