fix(intake): refine product photo uploads and document previews
This commit is contained in:
parent
cf82b30096
commit
e263b24f5a
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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">
|
||||
右侧按分类切换工作区
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user