merge: sync ai white background workspace
This commit is contained in:
commit
e5175cabb0
@ -10587,6 +10587,9 @@ describe("NonIntegratedDetailClient", () => {
|
||||
await actor.click(
|
||||
screen.getByTestId("product-photo-open-ai-white-background"),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("product-photo-ai-white-background-workspace"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("ai-white-background-grid")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("ai-white-background-control-card"),
|
||||
@ -10765,19 +10768,22 @@ describe("NonIntegratedDetailClient", () => {
|
||||
screen.getByTestId("product-photo-open-ai-white-background"),
|
||||
);
|
||||
|
||||
const aiWhiteBackgroundDialog = await screen.findByRole("dialog", {
|
||||
name: "AI 白底图",
|
||||
});
|
||||
const aiWhiteBackgroundWorkspace = await screen.findByTestId(
|
||||
"product-photo-ai-white-background-workspace",
|
||||
);
|
||||
|
||||
expect(
|
||||
within(aiWhiteBackgroundDialog).getByAltText("front-ai-white.png"),
|
||||
within(aiWhiteBackgroundWorkspace).getByAltText("front-ai-white.png"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(aiWhiteBackgroundDialog).queryByAltText("正视图源图.png"),
|
||||
within(aiWhiteBackgroundWorkspace).queryByAltText("正视图源图.png"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(aiWhiteBackgroundDialog).getByAltText("后视图源图.png"),
|
||||
within(aiWhiteBackgroundWorkspace).getByAltText("后视图源图.png"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "AI 白底图" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("generates only currently uploaded product-photo source views", async () => {
|
||||
|
||||
@ -141,6 +141,9 @@ import {
|
||||
import {
|
||||
markNonIntegratedAiWhiteBackgroundNeedsReview,
|
||||
} from "./workspaces/ai-white-background/nonIntegratedAiWhiteBackground";
|
||||
import {
|
||||
NonIntegratedAiWhiteBackgroundWorkspace,
|
||||
} from "./workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace";
|
||||
import { buildProductPayload } from "./nonIntegratedProductPayload";
|
||||
import { useNonIntegratedBomLibraryOptions } from "./useNonIntegratedBomLibraryOptions";
|
||||
import { useNonIntegratedComponentSkuRows } from "./useNonIntegratedComponentSkuRows";
|
||||
@ -226,6 +229,7 @@ import {
|
||||
type ProductDataGroup,
|
||||
type ProductPhotoGroup,
|
||||
type ProductPhotoSelection,
|
||||
type ProductPhotoWorkspaceMode,
|
||||
type UploadCategoryKey,
|
||||
type WorkflowNodeCategory,
|
||||
} from "./workspaces/documents/documentWorkspaceHelpers";
|
||||
@ -652,8 +656,8 @@ export default function NonIntegratedDetailClient({
|
||||
});
|
||||
const [selectedProductPhotoStyleKey, setSelectedProductPhotoStyleKey] =
|
||||
useState("");
|
||||
const [isAiWhiteBackgroundModalOpen, setIsAiWhiteBackgroundModalOpen] =
|
||||
useState(false);
|
||||
const [activeProductPhotoMode, setActiveProductPhotoMode] =
|
||||
useState<ProductPhotoWorkspaceMode>("source");
|
||||
const [selectedInstallationStyleKey, setSelectedInstallationStyleKey] =
|
||||
useState("");
|
||||
const [activeInstallationAssetType, setActiveInstallationAssetType] =
|
||||
@ -4026,6 +4030,7 @@ export default function NonIntegratedDetailClient({
|
||||
activeStyleEntry: activeProductPhotoStyleEntry,
|
||||
workspaceData: productPhotoWorkspaceData,
|
||||
activePanel: activeProductPhotoPanel,
|
||||
activeMode: activeProductPhotoMode,
|
||||
activePanelFiles: activeProductPhotoPanelFiles,
|
||||
activePanelTitle: activeProductPhotoPanelTitle,
|
||||
selectedTarget: selectedProductPhotoTarget,
|
||||
@ -4033,8 +4038,33 @@ export default function NonIntegratedDetailClient({
|
||||
isReadOnly,
|
||||
previewTitle: workspacePreviewTitle,
|
||||
previewDescription: workspacePreviewDescription,
|
||||
renderAiWhiteBackgroundContent: () => (
|
||||
<NonIntegratedAiWhiteBackgroundWorkspace
|
||||
productId={productId ?? null}
|
||||
isActive={
|
||||
isProductPhotoWorkspace &&
|
||||
activeProductPhotoMode === "ai-white-background"
|
||||
}
|
||||
isReadOnly={isReadOnly}
|
||||
canUseAiWhiteBackground={canUseAiWhiteBackground}
|
||||
currentProductPhotoStyleKey={currentProductPhotoStyleKey}
|
||||
currentProductPhotoStyleLabel={currentProductPhotoStyleLabel}
|
||||
displayedSkuItems={displayedSkuItems}
|
||||
skuItems={skuItems}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
uploadFiles={uploadFiles}
|
||||
productMetadata={productMetadata}
|
||||
sourceSlotFiles={productPhotoWorkspaceData.slotFiles}
|
||||
onProductSynced={syncAiWhiteBackgroundSavedProduct}
|
||||
onNotify={(message, type) => {
|
||||
setSaveMessage(message);
|
||||
setSaveMessageType(type);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
renderPreviewContent: renderDocumentWorkspacePreviewContent,
|
||||
onSelectStyle: handleSelectProductPhotoStyle,
|
||||
onSwitchMode: setActiveProductPhotoMode,
|
||||
onSwitchPanel: handleSwitchProductPhotoPanel,
|
||||
onOpenCurrentUpload: openProductPhotoCurrentUpload,
|
||||
onSelectSlot: handleSelectProductPhotoSlot,
|
||||
@ -4772,6 +4802,7 @@ export default function NonIntegratedDetailClient({
|
||||
activeInstallationAssetFiles,
|
||||
activeProductDataGroupFiles,
|
||||
activeProductPhotoDownloadFiles,
|
||||
activeProductPhotoMode,
|
||||
activeProductPhotoPanel,
|
||||
canDeleteSelectedReviewRound,
|
||||
canDownloadWorkspaceFiles,
|
||||
@ -4875,7 +4906,11 @@ export default function NonIntegratedDetailClient({
|
||||
onImportProductLimitWorkbookChange:
|
||||
handleImportProductLimitWorkbookChange,
|
||||
onOpenAiWhiteBackground: () =>
|
||||
setIsAiWhiteBackgroundModalOpen(true),
|
||||
setActiveProductPhotoMode((current) =>
|
||||
current === "ai-white-background"
|
||||
? "source"
|
||||
: "ai-white-background",
|
||||
),
|
||||
onOpenProductPhotoCurrentUpload:
|
||||
openProductPhotoCurrentUpload,
|
||||
onOpenProductPhotoPanelUpload: openProductPhotoPanelUpload,
|
||||
@ -4943,26 +4978,6 @@ export default function NonIntegratedDetailClient({
|
||||
)}
|
||||
</main>
|
||||
<DetailOverlayDialogs
|
||||
aiWhiteBackgroundProps={{
|
||||
isOpen: isAiWhiteBackgroundModalOpen,
|
||||
onClose: () => setIsAiWhiteBackgroundModalOpen(false),
|
||||
productId: productId ?? null,
|
||||
isReadOnly,
|
||||
canUseAiWhiteBackground,
|
||||
currentProductPhotoStyleKey,
|
||||
currentProductPhotoStyleLabel,
|
||||
displayedSkuItems,
|
||||
skuItems,
|
||||
materialLibraryFinishes,
|
||||
uploadFiles,
|
||||
productMetadata,
|
||||
sourceSlotFiles: productPhotoWorkspaceData.slotFiles,
|
||||
onProductSynced: syncAiWhiteBackgroundSavedProduct,
|
||||
onNotify: (message, type) => {
|
||||
setSaveMessage(message);
|
||||
setSaveMessageType(type);
|
||||
},
|
||||
}}
|
||||
barcodeScannerSkuId={barcodeScannerSkuId}
|
||||
bomImagePreview={bomImagePreview}
|
||||
canGoToNextWorkspaceImage={canGoToNextWorkspaceImage}
|
||||
|
||||
@ -1,573 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import Modal from "@/components/ui/Modal";
|
||||
import {
|
||||
AI_WHITE_BACKGROUND_SOURCE_SLOTS,
|
||||
AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS,
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS,
|
||||
AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT,
|
||||
AI_WHITE_BACKGROUND_VIEW_LABELS,
|
||||
getAiWhiteBackgroundSessionGeneratedFiles,
|
||||
getLatestAiWhiteBackgroundSession,
|
||||
hasAnyAiWhiteBackgroundGeneratedView,
|
||||
hasCompleteAiWhiteBackgroundSession,
|
||||
readNonIntegratedAiWhiteBackgroundMetadata,
|
||||
type AiWhiteBackgroundTargetFinishOption,
|
||||
type AiWhiteBackgroundSourceSlot,
|
||||
type AiWhiteBackgroundViewKey,
|
||||
} from "./nonIntegratedAiWhiteBackground";
|
||||
import type {
|
||||
NonIntegratedProductRecord,
|
||||
NonIntegratedSkuItem,
|
||||
NonIntegratedUploadFile,
|
||||
} from "../../nonIntegratedStorage";
|
||||
import {
|
||||
cleanupSharedNonIntegratedAiWhiteBackgroundManifest,
|
||||
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities,
|
||||
generateSharedNonIntegratedAiWhiteBackgroundSession,
|
||||
uploadSharedNonIntegratedAiWhiteBackgroundSession,
|
||||
} from "../../nonIntegratedApi";
|
||||
import NonIntegratedAiWhiteBackgroundPanel from "./NonIntegratedAiWhiteBackgroundPanel";
|
||||
|
||||
type AiWhiteBackgroundBarcodeOption = {
|
||||
barcode: string;
|
||||
label: string;
|
||||
batchNo: number;
|
||||
color: string;
|
||||
};
|
||||
NonIntegratedAiWhiteBackgroundWorkspace,
|
||||
type NonIntegratedAiWhiteBackgroundWorkspaceProps,
|
||||
} from "./NonIntegratedAiWhiteBackgroundWorkspace";
|
||||
|
||||
type NonIntegratedAiWhiteBackgroundModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
} & NonIntegratedAiWhiteBackgroundWorkspaceProps;
|
||||
|
||||
export type NonIntegratedAiWhiteBackgroundWorkspaceProps = {
|
||||
productId: string | null;
|
||||
isActive?: boolean;
|
||||
isReadOnly: boolean;
|
||||
canUseAiWhiteBackground: boolean;
|
||||
currentProductPhotoStyleKey: string;
|
||||
currentProductPhotoStyleLabel: string;
|
||||
displayedSkuItems: NonIntegratedSkuItem[];
|
||||
skuItems: NonIntegratedSkuItem[];
|
||||
materialLibraryFinishes: AiWhiteBackgroundTargetFinishOption[];
|
||||
uploadFiles: NonIntegratedUploadFile[];
|
||||
productMetadata: NonIntegratedProductRecord["metadata"] | undefined;
|
||||
sourceSlotFiles: Record<
|
||||
AiWhiteBackgroundSourceSlot,
|
||||
NonIntegratedUploadFile | null
|
||||
>;
|
||||
onProductSynced: (product: NonIntegratedProductRecord) => void;
|
||||
onNotify: (message: string, type: "success" | "error") => void;
|
||||
};
|
||||
|
||||
function getStyleKeyFromModel(model: string) {
|
||||
const match = model
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.match(/K\d{2}$/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
function buildClientAiWhiteBackgroundSessionId() {
|
||||
const suffix =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
return `ai-wbg-session-${Date.now()}-${suffix}`;
|
||||
}
|
||||
|
||||
export function NonIntegratedAiWhiteBackgroundWorkspace({
|
||||
productId,
|
||||
isActive = true,
|
||||
isReadOnly,
|
||||
canUseAiWhiteBackground,
|
||||
currentProductPhotoStyleKey,
|
||||
currentProductPhotoStyleLabel,
|
||||
displayedSkuItems,
|
||||
skuItems,
|
||||
materialLibraryFinishes,
|
||||
uploadFiles,
|
||||
productMetadata,
|
||||
sourceSlotFiles,
|
||||
onProductSynced,
|
||||
onNotify,
|
||||
}: NonIntegratedAiWhiteBackgroundWorkspaceProps) {
|
||||
const [selectedBarcode, setSelectedBarcode] = useState("");
|
||||
const [targetFinishId, setTargetFinishId] = useState("");
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [generatingViewKeys, setGeneratingViewKeys] = useState<
|
||||
AiWhiteBackgroundViewKey[]
|
||||
>([]);
|
||||
const [isBulkGenerating, setIsBulkGenerating] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [cleaningManifestId, setCleaningManifestId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [materialLibraryCapabilities, setMaterialLibraryCapabilities] = useState<{
|
||||
isLoading: boolean;
|
||||
canUploadToMaterialLibrary: boolean;
|
||||
uploadDisabledReason: string | null;
|
||||
}>({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: false,
|
||||
uploadDisabledReason: null,
|
||||
});
|
||||
|
||||
const aiWhiteBackgroundMetadata = useMemo(
|
||||
() => readNonIntegratedAiWhiteBackgroundMetadata(productMetadata),
|
||||
[productMetadata],
|
||||
);
|
||||
|
||||
const barcodeOptions = useMemo(() => {
|
||||
const withBarcode = (items: NonIntegratedSkuItem[]) =>
|
||||
items.filter((item) => item.barcode.trim());
|
||||
const currentStyleItems = withBarcode(
|
||||
displayedSkuItems.filter(
|
||||
(item) => getStyleKeyFromModel(item.model) === currentProductPhotoStyleKey,
|
||||
),
|
||||
);
|
||||
const displayedItems = withBarcode(displayedSkuItems);
|
||||
const fallbackStyleItems = withBarcode(
|
||||
skuItems.filter(
|
||||
(item) => getStyleKeyFromModel(item.model) === currentProductPhotoStyleKey,
|
||||
),
|
||||
);
|
||||
const sourceItems =
|
||||
currentStyleItems.length > 0
|
||||
? currentStyleItems
|
||||
: displayedItems.length > 0
|
||||
? displayedItems
|
||||
: fallbackStyleItems.length > 0
|
||||
? fallbackStyleItems
|
||||
: withBarcode(skuItems);
|
||||
const optionMap = new Map<string, AiWhiteBackgroundBarcodeOption>();
|
||||
|
||||
sourceItems.forEach((item) => {
|
||||
const barcode = item.barcode.trim().toUpperCase();
|
||||
if (!barcode || optionMap.has(barcode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemLabel = item.model.trim() || item.skuCode.trim() || item.id;
|
||||
const batchNo = item.batchNo || 1;
|
||||
optionMap.set(barcode, {
|
||||
barcode,
|
||||
label: `${itemLabel} / ${barcode} / 批次${batchNo}`,
|
||||
batchNo,
|
||||
color: item.color.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(optionMap.values());
|
||||
}, [currentProductPhotoStyleKey, displayedSkuItems, skuItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBarcode((current) =>
|
||||
barcodeOptions.some((option) => option.barcode === current)
|
||||
? current
|
||||
: (barcodeOptions[0]?.barcode ?? ""),
|
||||
);
|
||||
}, [barcodeOptions]);
|
||||
|
||||
const selectedBarcodeOption = useMemo(
|
||||
() => barcodeOptions.find((option) => option.barcode === selectedBarcode) ?? null,
|
||||
[barcodeOptions, selectedBarcode],
|
||||
);
|
||||
|
||||
const targetFinishOptions = useMemo(
|
||||
() => materialLibraryFinishes.filter((finish) => finish.isActive !== false),
|
||||
[materialLibraryFinishes],
|
||||
);
|
||||
const selectedTargetFinish = useMemo(
|
||||
() =>
|
||||
targetFinishOptions.find((finish) => finish.id === targetFinishId) ?? null,
|
||||
[targetFinishId, targetFinishOptions],
|
||||
);
|
||||
const targetColor = selectedTargetFinish?.name.trim() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
targetFinishId &&
|
||||
!targetFinishOptions.some((finish) => finish.id === targetFinishId)
|
||||
) {
|
||||
setTargetFinishId("");
|
||||
}
|
||||
}, [targetFinishId, targetFinishOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setTargetFinishId("");
|
||||
}, [currentProductPhotoStyleKey, productId, selectedBarcode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !productId || !canUseAiWhiteBackground) {
|
||||
setMaterialLibraryCapabilities({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: false,
|
||||
uploadDisabledReason: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setMaterialLibraryCapabilities((current) => ({
|
||||
...current,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
void fetchSharedNonIntegratedAiWhiteBackgroundCapabilities(productId)
|
||||
.then((result) => {
|
||||
if (disposed) return;
|
||||
setMaterialLibraryCapabilities({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: result.canUploadToMaterialLibrary,
|
||||
uploadDisabledReason: result.uploadDisabledReason,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (disposed) return;
|
||||
setMaterialLibraryCapabilities({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: false,
|
||||
uploadDisabledReason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "素材库上传能力获取失败。",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [canUseAiWhiteBackground, isActive, productId]);
|
||||
|
||||
const currentSession = useMemo(
|
||||
() =>
|
||||
selectedBarcode
|
||||
? getLatestAiWhiteBackgroundSession(productMetadata, {
|
||||
styleKey: currentProductPhotoStyleKey,
|
||||
barcode: selectedBarcode,
|
||||
targetColor,
|
||||
targetFinishId,
|
||||
})
|
||||
: null,
|
||||
[currentProductPhotoStyleKey, productMetadata, selectedBarcode, targetColor, targetFinishId],
|
||||
);
|
||||
|
||||
const generatedFiles = useMemo(
|
||||
() =>
|
||||
currentSession
|
||||
? getAiWhiteBackgroundSessionGeneratedFiles(uploadFiles, currentSession.id)
|
||||
: (Object.fromEntries(
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [viewKey, null]),
|
||||
) as Record<AiWhiteBackgroundViewKey, NonIntegratedUploadFile | null>),
|
||||
[currentSession, uploadFiles],
|
||||
);
|
||||
|
||||
const currentManifest = useMemo(() => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastManifestId = currentSession.lastManifestId?.trim();
|
||||
if (lastManifestId) {
|
||||
const matchedManifest = aiWhiteBackgroundMetadata.uploadManifests.find(
|
||||
(manifest) => manifest.manifestId === lastManifestId,
|
||||
);
|
||||
if (matchedManifest) {
|
||||
return matchedManifest;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
aiWhiteBackgroundMetadata.uploadManifests
|
||||
.filter((manifest) => manifest.sessionId === currentSession.id)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ??
|
||||
null
|
||||
);
|
||||
}, [aiWhiteBackgroundMetadata.uploadManifests, currentSession]);
|
||||
|
||||
const sourceReadyByView = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [
|
||||
viewKey,
|
||||
Boolean(sourceSlotFiles[AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]]),
|
||||
]),
|
||||
) as Record<AiWhiteBackgroundViewKey, boolean>,
|
||||
[sourceSlotFiles],
|
||||
);
|
||||
const sourceFilesByView = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [
|
||||
viewKey,
|
||||
sourceSlotFiles[AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]] ?? null,
|
||||
]),
|
||||
) as Record<AiWhiteBackgroundViewKey, NonIntegratedUploadFile | null>,
|
||||
[sourceSlotFiles],
|
||||
);
|
||||
|
||||
const missingSourceSlotLabels = useMemo(
|
||||
() =>
|
||||
AI_WHITE_BACKGROUND_SOURCE_SLOTS.filter((slot) => !sourceSlotFiles[slot]).map(
|
||||
(slot) => AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS[slot],
|
||||
),
|
||||
[sourceSlotFiles],
|
||||
);
|
||||
|
||||
const availableSourceViewKeys = useMemo(
|
||||
() =>
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.filter((viewKey) => sourceReadyByView[viewKey]),
|
||||
[sourceReadyByView],
|
||||
);
|
||||
const availableSourceViewCount = availableSourceViewKeys.length;
|
||||
const generatingViewKeySet = useMemo(
|
||||
() => new Set(generatingViewKeys),
|
||||
[generatingViewKeys],
|
||||
);
|
||||
const isGenerating = generatingViewKeys.length > 0;
|
||||
const canUploadToMaterialLibrary =
|
||||
materialLibraryCapabilities.canUploadToMaterialLibrary;
|
||||
const uploadDisabledReason = materialLibraryCapabilities.isLoading
|
||||
? "正在检查素材库上传接口配置…"
|
||||
: materialLibraryCapabilities.uploadDisabledReason;
|
||||
const generateProgressText = useMemo(() => {
|
||||
if (isBulkGenerating && generatingViewKeys.length > 0) {
|
||||
return `正在生成 ${generatingViewKeys.length} 个角度的白底图…`;
|
||||
}
|
||||
if (generatingViewKeys.length === 1) {
|
||||
return `正在生成 ${AI_WHITE_BACKGROUND_VIEW_LABELS[generatingViewKeys[0]]} 白底图…`;
|
||||
}
|
||||
if (generatingViewKeys.length > 1) {
|
||||
return `正在并发生成 ${generatingViewKeys.length} 个角度的白底图…`;
|
||||
}
|
||||
return "";
|
||||
}, [generatingViewKeys, isBulkGenerating]);
|
||||
const sessionStatus = currentSession?.status ?? null;
|
||||
const canUpload =
|
||||
canUseAiWhiteBackground &&
|
||||
Boolean(selectedBarcode) &&
|
||||
hasAnyAiWhiteBackgroundGeneratedView(currentSession) &&
|
||||
canUploadToMaterialLibrary;
|
||||
const statusText = !canUseAiWhiteBackground
|
||||
? "当前环境未启用服务端白底图能力"
|
||||
: isGenerating && generateProgressText
|
||||
? generateProgressText
|
||||
: currentManifest
|
||||
? `上传清单:${currentManifest.status}`
|
||||
: currentSession
|
||||
? `当前会话:${currentSession.status}`
|
||||
: "还没有生成白底图会话";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setPendingSessionId(null);
|
||||
setGeneratingViewKeys([]);
|
||||
setIsBulkGenerating(false);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingSessionId(null);
|
||||
setGeneratingViewKeys([]);
|
||||
setIsBulkGenerating(false);
|
||||
}, [currentProductPhotoStyleKey, productId, selectedBarcode, targetFinishId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSession?.id) {
|
||||
setPendingSessionId((current) =>
|
||||
current === currentSession.id ? null : current,
|
||||
);
|
||||
}
|
||||
}, [currentSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (generatingViewKeys.length === 0 && !currentSession?.id) {
|
||||
setPendingSessionId(null);
|
||||
}
|
||||
}, [currentSession?.id, generatingViewKeys.length]);
|
||||
|
||||
async function handleGenerate(requestedViewKeys?: AiWhiteBackgroundViewKey[]) {
|
||||
if (
|
||||
isReadOnly ||
|
||||
!productId ||
|
||||
!canUseAiWhiteBackground ||
|
||||
!selectedBarcode ||
|
||||
availableSourceViewCount === 0 ||
|
||||
(requestedViewKeys == null && generatingViewKeys.length > 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveRequestedViewKeys =
|
||||
requestedViewKeys && requestedViewKeys.length > 0
|
||||
? requestedViewKeys
|
||||
: availableSourceViewKeys;
|
||||
const filteredRequestedViewKeys = effectiveRequestedViewKeys.filter(
|
||||
(viewKey) =>
|
||||
sourceReadyByView[viewKey] &&
|
||||
!generatingViewKeySet.has(viewKey) &&
|
||||
!isBulkGenerating,
|
||||
);
|
||||
if (filteredRequestedViewKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSessionId =
|
||||
currentSession?.id ??
|
||||
pendingSessionId ??
|
||||
buildClientAiWhiteBackgroundSessionId();
|
||||
const isSingleViewRequest = filteredRequestedViewKeys.length === 1;
|
||||
|
||||
try {
|
||||
if (!currentSession?.id && !pendingSessionId) {
|
||||
setPendingSessionId(requestSessionId);
|
||||
}
|
||||
if (!isSingleViewRequest) {
|
||||
setIsBulkGenerating(true);
|
||||
}
|
||||
setGeneratingViewKeys((current) =>
|
||||
Array.from(new Set([...current, ...filteredRequestedViewKeys])),
|
||||
);
|
||||
|
||||
const result = await generateSharedNonIntegratedAiWhiteBackgroundSession(
|
||||
productId,
|
||||
{
|
||||
sessionId: requestSessionId,
|
||||
styleKey: currentProductPhotoStyleKey,
|
||||
barcode: selectedBarcode,
|
||||
targetColor: targetColor.trim() || null,
|
||||
targetFinishId: selectedTargetFinish?.id ?? null,
|
||||
requestedViewKeys: filteredRequestedViewKeys,
|
||||
},
|
||||
);
|
||||
onProductSynced(result.product);
|
||||
|
||||
onNotify(
|
||||
isSingleViewRequest
|
||||
? `${AI_WHITE_BACKGROUND_VIEW_LABELS[filteredRequestedViewKeys[0]]} 白底图已更新`
|
||||
: hasCompleteAiWhiteBackgroundSession(result.session)
|
||||
? "8 个固定角度白底图已生成完成"
|
||||
: `已生成 ${filteredRequestedViewKeys.length} 个已上传角度的白底图,可先确认上传已完成的视角`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
onNotify(
|
||||
error instanceof Error ? error.message : "AI 白底图生成失败,请稍后重试",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setGeneratingViewKeys((current) =>
|
||||
current.filter((viewKey) => !filteredRequestedViewKeys.includes(viewKey)),
|
||||
);
|
||||
if (!isSingleViewRequest) {
|
||||
setIsBulkGenerating(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (
|
||||
isReadOnly ||
|
||||
!productId ||
|
||||
!canUseAiWhiteBackground ||
|
||||
!currentSession ||
|
||||
!canUploadToMaterialLibrary ||
|
||||
!canUpload ||
|
||||
isUploading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const result = await uploadSharedNonIntegratedAiWhiteBackgroundSession(
|
||||
productId,
|
||||
currentSession.id,
|
||||
);
|
||||
|
||||
onProductSynced(result.product);
|
||||
onNotify("白底图已确认上传到素材库", "success");
|
||||
} catch (error) {
|
||||
onNotify(
|
||||
error instanceof Error ? error.message : "白底图上传素材库失败,请稍后重试",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if (
|
||||
isReadOnly ||
|
||||
!productId ||
|
||||
!canUseAiWhiteBackground ||
|
||||
!currentManifest ||
|
||||
cleaningManifestId === currentManifest.manifestId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCleaningManifestId(currentManifest.manifestId);
|
||||
const result = await cleanupSharedNonIntegratedAiWhiteBackgroundManifest(
|
||||
productId,
|
||||
currentManifest.manifestId,
|
||||
"manual-review-cleanup",
|
||||
);
|
||||
|
||||
onProductSynced(result.product);
|
||||
onNotify("当前白底图上传清单已完成清理", "success");
|
||||
} catch (error) {
|
||||
onNotify(
|
||||
error instanceof Error ? error.message : "白底图清理失败,请稍后重试",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setCleaningManifestId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NonIntegratedAiWhiteBackgroundPanel
|
||||
styleLabel={currentProductPhotoStyleLabel}
|
||||
styleKey={currentProductPhotoStyleKey}
|
||||
selectedBarcode={selectedBarcode}
|
||||
targetColor={targetColor}
|
||||
targetFinishId={targetFinishId}
|
||||
defaultTargetColor={selectedBarcodeOption?.color ?? ""}
|
||||
targetFinishOptions={targetFinishOptions}
|
||||
barcodeOptions={barcodeOptions}
|
||||
statusText={statusText}
|
||||
sessionStatus={sessionStatus}
|
||||
missingSourceSlotLabels={missingSourceSlotLabels}
|
||||
availableSourceViewCount={availableSourceViewCount}
|
||||
canUpload={canUpload}
|
||||
canUploadToMaterialLibrary={canUploadToMaterialLibrary}
|
||||
generatedFiles={generatedFiles}
|
||||
sourceFilesByView={sourceFilesByView}
|
||||
currentManifest={currentManifest}
|
||||
sourceReadyByView={sourceReadyByView}
|
||||
isReadOnly={isReadOnly}
|
||||
canUseAiWhiteBackground={canUseAiWhiteBackground}
|
||||
uploadDisabledReason={uploadDisabledReason}
|
||||
isBulkGenerating={isBulkGenerating}
|
||||
generatingViewKeys={generatingViewKeys}
|
||||
isUploading={isUploading}
|
||||
cleaningManifestId={cleaningManifestId}
|
||||
onSelectedBarcodeChange={setSelectedBarcode}
|
||||
onTargetFinishChange={setTargetFinishId}
|
||||
onGenerate={handleGenerate}
|
||||
onUpload={handleUpload}
|
||||
onCleanup={() => void handleCleanup()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NonIntegratedAiWhiteBackgroundModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@ -589,7 +33,7 @@ export default function NonIntegratedAiWhiteBackgroundModal({
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<h2 className="text-[18px] font-black tracking-[-0.02em] text-[var(--obs-shell-title)]">
|
||||
<h2 className="text-[18px] font-black text-[var(--obs-shell-title)]">
|
||||
AI 白底图
|
||||
</h2>
|
||||
<span className="truncate text-[14px] font-apple-semibold text-[var(--obs-shell-title)]">
|
||||
|
||||
@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AI_WHITE_BACKGROUND_SOURCE_SLOTS,
|
||||
AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS,
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS,
|
||||
AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT,
|
||||
AI_WHITE_BACKGROUND_VIEW_LABELS,
|
||||
getAiWhiteBackgroundSessionGeneratedFiles,
|
||||
getLatestAiWhiteBackgroundSession,
|
||||
hasAnyAiWhiteBackgroundGeneratedView,
|
||||
hasCompleteAiWhiteBackgroundSession,
|
||||
readNonIntegratedAiWhiteBackgroundMetadata,
|
||||
type AiWhiteBackgroundTargetFinishOption,
|
||||
type AiWhiteBackgroundSourceSlot,
|
||||
type AiWhiteBackgroundViewKey,
|
||||
} from "./nonIntegratedAiWhiteBackground";
|
||||
import type {
|
||||
NonIntegratedProductRecord,
|
||||
NonIntegratedSkuItem,
|
||||
NonIntegratedUploadFile,
|
||||
} from "../../nonIntegratedStorage";
|
||||
import {
|
||||
cleanupSharedNonIntegratedAiWhiteBackgroundManifest,
|
||||
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities,
|
||||
generateSharedNonIntegratedAiWhiteBackgroundSession,
|
||||
uploadSharedNonIntegratedAiWhiteBackgroundSession,
|
||||
} from "../../nonIntegratedApi";
|
||||
import NonIntegratedAiWhiteBackgroundPanel from "./NonIntegratedAiWhiteBackgroundPanel";
|
||||
|
||||
type AiWhiteBackgroundBarcodeOption = {
|
||||
barcode: string;
|
||||
label: string;
|
||||
batchNo: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type NonIntegratedAiWhiteBackgroundWorkspaceProps = {
|
||||
productId: string | null;
|
||||
isActive?: boolean;
|
||||
isReadOnly: boolean;
|
||||
canUseAiWhiteBackground: boolean;
|
||||
currentProductPhotoStyleKey: string;
|
||||
currentProductPhotoStyleLabel: string;
|
||||
displayedSkuItems: NonIntegratedSkuItem[];
|
||||
skuItems: NonIntegratedSkuItem[];
|
||||
materialLibraryFinishes: AiWhiteBackgroundTargetFinishOption[];
|
||||
uploadFiles: NonIntegratedUploadFile[];
|
||||
productMetadata: NonIntegratedProductRecord["metadata"] | undefined;
|
||||
sourceSlotFiles: Record<
|
||||
AiWhiteBackgroundSourceSlot,
|
||||
NonIntegratedUploadFile | null
|
||||
>;
|
||||
onProductSynced: (product: NonIntegratedProductRecord) => void;
|
||||
onNotify: (message: string, type: "success" | "error") => void;
|
||||
};
|
||||
|
||||
function getStyleKeyFromModel(model: string) {
|
||||
const match = model
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.match(/K\d{2}$/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
function buildClientAiWhiteBackgroundSessionId() {
|
||||
const suffix =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
return `ai-wbg-session-${Date.now()}-${suffix}`;
|
||||
}
|
||||
|
||||
export function NonIntegratedAiWhiteBackgroundWorkspace({
|
||||
productId,
|
||||
isActive = true,
|
||||
isReadOnly,
|
||||
canUseAiWhiteBackground,
|
||||
currentProductPhotoStyleKey,
|
||||
currentProductPhotoStyleLabel,
|
||||
displayedSkuItems,
|
||||
skuItems,
|
||||
materialLibraryFinishes,
|
||||
uploadFiles,
|
||||
productMetadata,
|
||||
sourceSlotFiles,
|
||||
onProductSynced,
|
||||
onNotify,
|
||||
}: NonIntegratedAiWhiteBackgroundWorkspaceProps) {
|
||||
const [selectedBarcode, setSelectedBarcode] = useState("");
|
||||
const [targetFinishId, setTargetFinishId] = useState("");
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [generatingViewKeys, setGeneratingViewKeys] = useState<
|
||||
AiWhiteBackgroundViewKey[]
|
||||
>([]);
|
||||
const [isBulkGenerating, setIsBulkGenerating] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [cleaningManifestId, setCleaningManifestId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [materialLibraryCapabilities, setMaterialLibraryCapabilities] = useState<{
|
||||
isLoading: boolean;
|
||||
canUploadToMaterialLibrary: boolean;
|
||||
uploadDisabledReason: string | null;
|
||||
}>({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: false,
|
||||
uploadDisabledReason: null,
|
||||
});
|
||||
|
||||
const aiWhiteBackgroundMetadata = useMemo(
|
||||
() => readNonIntegratedAiWhiteBackgroundMetadata(productMetadata),
|
||||
[productMetadata],
|
||||
);
|
||||
|
||||
const barcodeOptions = useMemo(() => {
|
||||
const withBarcode = (items: NonIntegratedSkuItem[]) =>
|
||||
items.filter((item) => item.barcode.trim());
|
||||
const currentStyleItems = withBarcode(
|
||||
displayedSkuItems.filter(
|
||||
(item) => getStyleKeyFromModel(item.model) === currentProductPhotoStyleKey,
|
||||
),
|
||||
);
|
||||
const displayedItems = withBarcode(displayedSkuItems);
|
||||
const fallbackStyleItems = withBarcode(
|
||||
skuItems.filter(
|
||||
(item) => getStyleKeyFromModel(item.model) === currentProductPhotoStyleKey,
|
||||
),
|
||||
);
|
||||
const sourceItems =
|
||||
currentStyleItems.length > 0
|
||||
? currentStyleItems
|
||||
: displayedItems.length > 0
|
||||
? displayedItems
|
||||
: fallbackStyleItems.length > 0
|
||||
? fallbackStyleItems
|
||||
: withBarcode(skuItems);
|
||||
const optionMap = new Map<string, AiWhiteBackgroundBarcodeOption>();
|
||||
|
||||
sourceItems.forEach((item) => {
|
||||
const barcode = item.barcode.trim().toUpperCase();
|
||||
if (!barcode || optionMap.has(barcode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemLabel = item.model.trim() || item.skuCode.trim() || item.id;
|
||||
const batchNo = item.batchNo || 1;
|
||||
optionMap.set(barcode, {
|
||||
barcode,
|
||||
label: `${itemLabel} / ${barcode} / 批次${batchNo}`,
|
||||
batchNo,
|
||||
color: item.color.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(optionMap.values());
|
||||
}, [currentProductPhotoStyleKey, displayedSkuItems, skuItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBarcode((current) =>
|
||||
barcodeOptions.some((option) => option.barcode === current)
|
||||
? current
|
||||
: (barcodeOptions[0]?.barcode ?? ""),
|
||||
);
|
||||
}, [barcodeOptions]);
|
||||
|
||||
const selectedBarcodeOption = useMemo(
|
||||
() => barcodeOptions.find((option) => option.barcode === selectedBarcode) ?? null,
|
||||
[barcodeOptions, selectedBarcode],
|
||||
);
|
||||
|
||||
const targetFinishOptions = useMemo(
|
||||
() => materialLibraryFinishes.filter((finish) => finish.isActive !== false),
|
||||
[materialLibraryFinishes],
|
||||
);
|
||||
const selectedTargetFinish = useMemo(
|
||||
() =>
|
||||
targetFinishOptions.find((finish) => finish.id === targetFinishId) ?? null,
|
||||
[targetFinishId, targetFinishOptions],
|
||||
);
|
||||
const targetColor = selectedTargetFinish?.name.trim() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
targetFinishId &&
|
||||
!targetFinishOptions.some((finish) => finish.id === targetFinishId)
|
||||
) {
|
||||
setTargetFinishId("");
|
||||
}
|
||||
}, [targetFinishId, targetFinishOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setTargetFinishId("");
|
||||
}, [currentProductPhotoStyleKey, productId, selectedBarcode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !productId || !canUseAiWhiteBackground) {
|
||||
setMaterialLibraryCapabilities({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: false,
|
||||
uploadDisabledReason: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setMaterialLibraryCapabilities((current) => ({
|
||||
...current,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
void fetchSharedNonIntegratedAiWhiteBackgroundCapabilities(productId)
|
||||
.then((result) => {
|
||||
if (disposed) return;
|
||||
setMaterialLibraryCapabilities({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: result.canUploadToMaterialLibrary,
|
||||
uploadDisabledReason: result.uploadDisabledReason,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (disposed) return;
|
||||
setMaterialLibraryCapabilities({
|
||||
isLoading: false,
|
||||
canUploadToMaterialLibrary: false,
|
||||
uploadDisabledReason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "素材库上传能力获取失败。",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [canUseAiWhiteBackground, isActive, productId]);
|
||||
|
||||
const currentSession = useMemo(
|
||||
() =>
|
||||
selectedBarcode
|
||||
? getLatestAiWhiteBackgroundSession(productMetadata, {
|
||||
styleKey: currentProductPhotoStyleKey,
|
||||
barcode: selectedBarcode,
|
||||
targetColor,
|
||||
targetFinishId,
|
||||
})
|
||||
: null,
|
||||
[currentProductPhotoStyleKey, productMetadata, selectedBarcode, targetColor, targetFinishId],
|
||||
);
|
||||
|
||||
const generatedFiles = useMemo(
|
||||
() =>
|
||||
currentSession
|
||||
? getAiWhiteBackgroundSessionGeneratedFiles(uploadFiles, currentSession.id)
|
||||
: (Object.fromEntries(
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [viewKey, null]),
|
||||
) as Record<AiWhiteBackgroundViewKey, NonIntegratedUploadFile | null>),
|
||||
[currentSession, uploadFiles],
|
||||
);
|
||||
|
||||
const currentManifest = useMemo(() => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastManifestId = currentSession.lastManifestId?.trim();
|
||||
if (lastManifestId) {
|
||||
const matchedManifest = aiWhiteBackgroundMetadata.uploadManifests.find(
|
||||
(manifest) => manifest.manifestId === lastManifestId,
|
||||
);
|
||||
if (matchedManifest) {
|
||||
return matchedManifest;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
aiWhiteBackgroundMetadata.uploadManifests
|
||||
.filter((manifest) => manifest.sessionId === currentSession.id)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ??
|
||||
null
|
||||
);
|
||||
}, [aiWhiteBackgroundMetadata.uploadManifests, currentSession]);
|
||||
|
||||
const sourceReadyByView = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [
|
||||
viewKey,
|
||||
Boolean(sourceSlotFiles[AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]]),
|
||||
]),
|
||||
) as Record<AiWhiteBackgroundViewKey, boolean>,
|
||||
[sourceSlotFiles],
|
||||
);
|
||||
const sourceFilesByView = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [
|
||||
viewKey,
|
||||
sourceSlotFiles[AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]] ?? null,
|
||||
]),
|
||||
) as Record<AiWhiteBackgroundViewKey, NonIntegratedUploadFile | null>,
|
||||
[sourceSlotFiles],
|
||||
);
|
||||
|
||||
const missingSourceSlotLabels = useMemo(
|
||||
() =>
|
||||
AI_WHITE_BACKGROUND_SOURCE_SLOTS.filter((slot) => !sourceSlotFiles[slot]).map(
|
||||
(slot) => AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS[slot],
|
||||
),
|
||||
[sourceSlotFiles],
|
||||
);
|
||||
|
||||
const availableSourceViewKeys = useMemo(
|
||||
() =>
|
||||
AI_WHITE_BACKGROUND_VIEW_KEYS.filter((viewKey) => sourceReadyByView[viewKey]),
|
||||
[sourceReadyByView],
|
||||
);
|
||||
const availableSourceViewCount = availableSourceViewKeys.length;
|
||||
const generatingViewKeySet = useMemo(
|
||||
() => new Set(generatingViewKeys),
|
||||
[generatingViewKeys],
|
||||
);
|
||||
const isGenerating = generatingViewKeys.length > 0;
|
||||
const canUploadToMaterialLibrary =
|
||||
materialLibraryCapabilities.canUploadToMaterialLibrary;
|
||||
const uploadDisabledReason = materialLibraryCapabilities.isLoading
|
||||
? "正在检查素材库上传接口配置…"
|
||||
: materialLibraryCapabilities.uploadDisabledReason;
|
||||
const generateProgressText = useMemo(() => {
|
||||
if (isBulkGenerating && generatingViewKeys.length > 0) {
|
||||
return `正在生成 ${generatingViewKeys.length} 个角度的白底图…`;
|
||||
}
|
||||
if (generatingViewKeys.length === 1) {
|
||||
return `正在生成 ${AI_WHITE_BACKGROUND_VIEW_LABELS[generatingViewKeys[0]]} 白底图…`;
|
||||
}
|
||||
if (generatingViewKeys.length > 1) {
|
||||
return `正在并发生成 ${generatingViewKeys.length} 个角度的白底图…`;
|
||||
}
|
||||
return "";
|
||||
}, [generatingViewKeys, isBulkGenerating]);
|
||||
const sessionStatus = currentSession?.status ?? null;
|
||||
const canUpload =
|
||||
canUseAiWhiteBackground &&
|
||||
Boolean(selectedBarcode) &&
|
||||
hasAnyAiWhiteBackgroundGeneratedView(currentSession) &&
|
||||
canUploadToMaterialLibrary;
|
||||
const statusText = !canUseAiWhiteBackground
|
||||
? "当前环境未启用服务端白底图能力"
|
||||
: isGenerating && generateProgressText
|
||||
? generateProgressText
|
||||
: currentManifest
|
||||
? `上传清单:${currentManifest.status}`
|
||||
: currentSession
|
||||
? `当前会话:${currentSession.status}`
|
||||
: "还没有生成白底图会话";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setPendingSessionId(null);
|
||||
setGeneratingViewKeys([]);
|
||||
setIsBulkGenerating(false);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingSessionId(null);
|
||||
setGeneratingViewKeys([]);
|
||||
setIsBulkGenerating(false);
|
||||
}, [currentProductPhotoStyleKey, productId, selectedBarcode, targetFinishId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSession?.id) {
|
||||
setPendingSessionId((current) =>
|
||||
current === currentSession.id ? null : current,
|
||||
);
|
||||
}
|
||||
}, [currentSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (generatingViewKeys.length === 0 && !currentSession?.id) {
|
||||
setPendingSessionId(null);
|
||||
}
|
||||
}, [currentSession?.id, generatingViewKeys.length]);
|
||||
|
||||
async function handleGenerate(requestedViewKeys?: AiWhiteBackgroundViewKey[]) {
|
||||
if (
|
||||
isReadOnly ||
|
||||
!productId ||
|
||||
!canUseAiWhiteBackground ||
|
||||
!selectedBarcode ||
|
||||
availableSourceViewCount === 0 ||
|
||||
(requestedViewKeys == null && generatingViewKeys.length > 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveRequestedViewKeys =
|
||||
requestedViewKeys && requestedViewKeys.length > 0
|
||||
? requestedViewKeys
|
||||
: availableSourceViewKeys;
|
||||
const filteredRequestedViewKeys = effectiveRequestedViewKeys.filter(
|
||||
(viewKey) =>
|
||||
sourceReadyByView[viewKey] &&
|
||||
!generatingViewKeySet.has(viewKey) &&
|
||||
!isBulkGenerating,
|
||||
);
|
||||
if (filteredRequestedViewKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSessionId =
|
||||
currentSession?.id ??
|
||||
pendingSessionId ??
|
||||
buildClientAiWhiteBackgroundSessionId();
|
||||
const isSingleViewRequest = filteredRequestedViewKeys.length === 1;
|
||||
|
||||
try {
|
||||
if (!currentSession?.id && !pendingSessionId) {
|
||||
setPendingSessionId(requestSessionId);
|
||||
}
|
||||
if (!isSingleViewRequest) {
|
||||
setIsBulkGenerating(true);
|
||||
}
|
||||
setGeneratingViewKeys((current) =>
|
||||
Array.from(new Set([...current, ...filteredRequestedViewKeys])),
|
||||
);
|
||||
|
||||
const result = await generateSharedNonIntegratedAiWhiteBackgroundSession(
|
||||
productId,
|
||||
{
|
||||
sessionId: requestSessionId,
|
||||
styleKey: currentProductPhotoStyleKey,
|
||||
barcode: selectedBarcode,
|
||||
targetColor: targetColor.trim() || null,
|
||||
targetFinishId: selectedTargetFinish?.id ?? null,
|
||||
requestedViewKeys: filteredRequestedViewKeys,
|
||||
},
|
||||
);
|
||||
onProductSynced(result.product);
|
||||
|
||||
onNotify(
|
||||
isSingleViewRequest
|
||||
? `${AI_WHITE_BACKGROUND_VIEW_LABELS[filteredRequestedViewKeys[0]]} 白底图已更新`
|
||||
: hasCompleteAiWhiteBackgroundSession(result.session)
|
||||
? "8 个固定角度白底图已生成完成"
|
||||
: `已生成 ${filteredRequestedViewKeys.length} 个已上传角度的白底图,可先确认上传已完成的视角`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
onNotify(
|
||||
error instanceof Error ? error.message : "AI 白底图生成失败,请稍后重试",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setGeneratingViewKeys((current) =>
|
||||
current.filter((viewKey) => !filteredRequestedViewKeys.includes(viewKey)),
|
||||
);
|
||||
if (!isSingleViewRequest) {
|
||||
setIsBulkGenerating(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (
|
||||
isReadOnly ||
|
||||
!productId ||
|
||||
!canUseAiWhiteBackground ||
|
||||
!currentSession ||
|
||||
!canUploadToMaterialLibrary ||
|
||||
!canUpload ||
|
||||
isUploading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const result = await uploadSharedNonIntegratedAiWhiteBackgroundSession(
|
||||
productId,
|
||||
currentSession.id,
|
||||
);
|
||||
|
||||
onProductSynced(result.product);
|
||||
onNotify("白底图已确认上传到素材库", "success");
|
||||
} catch (error) {
|
||||
onNotify(
|
||||
error instanceof Error ? error.message : "白底图上传素材库失败,请稍后重试",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if (
|
||||
isReadOnly ||
|
||||
!productId ||
|
||||
!canUseAiWhiteBackground ||
|
||||
!currentManifest ||
|
||||
cleaningManifestId === currentManifest.manifestId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCleaningManifestId(currentManifest.manifestId);
|
||||
const result = await cleanupSharedNonIntegratedAiWhiteBackgroundManifest(
|
||||
productId,
|
||||
currentManifest.manifestId,
|
||||
"manual-review-cleanup",
|
||||
);
|
||||
|
||||
onProductSynced(result.product);
|
||||
onNotify("当前白底图上传清单已完成清理", "success");
|
||||
} catch (error) {
|
||||
onNotify(
|
||||
error instanceof Error ? error.message : "白底图清理失败,请稍后重试",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setCleaningManifestId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NonIntegratedAiWhiteBackgroundPanel
|
||||
styleLabel={currentProductPhotoStyleLabel}
|
||||
styleKey={currentProductPhotoStyleKey}
|
||||
selectedBarcode={selectedBarcode}
|
||||
targetColor={targetColor}
|
||||
targetFinishId={targetFinishId}
|
||||
defaultTargetColor={selectedBarcodeOption?.color ?? ""}
|
||||
targetFinishOptions={targetFinishOptions}
|
||||
barcodeOptions={barcodeOptions}
|
||||
statusText={statusText}
|
||||
sessionStatus={sessionStatus}
|
||||
missingSourceSlotLabels={missingSourceSlotLabels}
|
||||
availableSourceViewCount={availableSourceViewCount}
|
||||
canUpload={canUpload}
|
||||
canUploadToMaterialLibrary={canUploadToMaterialLibrary}
|
||||
generatedFiles={generatedFiles}
|
||||
sourceFilesByView={sourceFilesByView}
|
||||
currentManifest={currentManifest}
|
||||
sourceReadyByView={sourceReadyByView}
|
||||
isReadOnly={isReadOnly}
|
||||
canUseAiWhiteBackground={canUseAiWhiteBackground}
|
||||
uploadDisabledReason={uploadDisabledReason}
|
||||
isBulkGenerating={isBulkGenerating}
|
||||
generatingViewKeys={generatingViewKeys}
|
||||
isUploading={isUploading}
|
||||
cleaningManifestId={cleaningManifestId}
|
||||
onSelectedBarcodeChange={setSelectedBarcode}
|
||||
onTargetFinishChange={setTargetFinishId}
|
||||
onGenerate={handleGenerate}
|
||||
onUpload={handleUpload}
|
||||
onCleanup={() => void handleCleanup()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
|
||||
import type {
|
||||
ProductPhotoGroup,
|
||||
ProductPhotoSelection,
|
||||
ProductPhotoWorkspaceMode,
|
||||
} from "./documentWorkspaceHelpers";
|
||||
|
||||
type ProductPhotoHeaderActionsProps = {
|
||||
@ -15,6 +16,7 @@ type ProductPhotoHeaderActionsProps = {
|
||||
currentUploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
detailUploadKey: string;
|
||||
activePanel: ProductPhotoGroup;
|
||||
activeMode: ProductPhotoWorkspaceMode;
|
||||
selectedTarget: ProductPhotoSelection;
|
||||
groupUploadLabel: string;
|
||||
currentUploadLabel: string;
|
||||
@ -41,6 +43,7 @@ export function ProductPhotoHeaderActions({
|
||||
currentUploadInputRef,
|
||||
detailUploadKey,
|
||||
activePanel,
|
||||
activeMode,
|
||||
selectedTarget,
|
||||
groupUploadLabel,
|
||||
currentUploadLabel,
|
||||
@ -60,15 +63,15 @@ export function ProductPhotoHeaderActions({
|
||||
return (
|
||||
<div className={actionGroupClassName}>
|
||||
<Button
|
||||
variant="plain"
|
||||
variant={activeMode === "ai-white-background" ? "filled" : "plain"}
|
||||
size="small"
|
||||
data-testid="product-photo-open-ai-white-background"
|
||||
onClick={onOpenAiWhiteBackground}
|
||||
disabled={!canUseAiWhiteBackground}
|
||||
>
|
||||
AI 白底图
|
||||
{activeMode === "ai-white-background" ? "实拍资料" : "AI 白底图"}
|
||||
</Button>
|
||||
{isReadOnly ? null : (
|
||||
{isReadOnly || activeMode === "ai-white-background" ? null : (
|
||||
<>
|
||||
<Button size="small" onClick={() => onOpenPanelUpload(activePanel)}>
|
||||
{groupUploadLabel}
|
||||
@ -101,7 +104,7 @@ export function ProductPhotoHeaderActions({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
{canDownloadWorkspaceFiles && activeMode === "source" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="plain"
|
||||
@ -122,7 +125,7 @@ export function ProductPhotoHeaderActions({
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{isReadOnly ? null : (
|
||||
{isReadOnly || activeMode === "ai-white-background" ? null : (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
type ProductPhotoGroup,
|
||||
type ProductPhotoNineViewSlot,
|
||||
type ProductPhotoSelection,
|
||||
type ProductPhotoWorkspaceMode,
|
||||
} from "./documentWorkspaceHelpers";
|
||||
import {
|
||||
getUploadFileTypeLabel,
|
||||
@ -23,6 +24,7 @@ type ProductPhotoWorkspaceProps = {
|
||||
activeStyleEntry: ProductPhotoStyleEntry | null;
|
||||
workspaceData: ProductPhotoWorkspaceData;
|
||||
activePanel: ProductPhotoGroup;
|
||||
activeMode: ProductPhotoWorkspaceMode;
|
||||
activePanelFiles: NonIntegratedUploadFile[];
|
||||
activePanelTitle: string;
|
||||
selectedTarget: ProductPhotoSelection;
|
||||
@ -31,7 +33,9 @@ type ProductPhotoWorkspaceProps = {
|
||||
previewTitle: string;
|
||||
previewDescription: string;
|
||||
previewContent: ReactNode;
|
||||
aiWhiteBackgroundContent: ReactNode;
|
||||
onSelectStyle: (styleKey: string) => void;
|
||||
onSwitchMode: (mode: ProductPhotoWorkspaceMode) => void;
|
||||
onSwitchPanel: (panel: ProductPhotoGroup) => void;
|
||||
onOpenCurrentUpload: (target: ProductPhotoSelection) => void;
|
||||
onSelectSlot: (slot: ProductPhotoNineViewSlot) => void;
|
||||
@ -44,6 +48,7 @@ export function ProductPhotoWorkspace({
|
||||
activeStyleEntry,
|
||||
workspaceData,
|
||||
activePanel,
|
||||
activeMode,
|
||||
activePanelFiles,
|
||||
activePanelTitle,
|
||||
selectedTarget,
|
||||
@ -52,16 +57,46 @@ export function ProductPhotoWorkspace({
|
||||
previewTitle,
|
||||
previewDescription,
|
||||
previewContent,
|
||||
aiWhiteBackgroundContent,
|
||||
onSelectStyle,
|
||||
onSwitchMode,
|
||||
onSwitchPanel,
|
||||
onOpenCurrentUpload,
|
||||
onSelectSlot,
|
||||
onOpenPanelUpload,
|
||||
onSelectGroupedFile,
|
||||
}: ProductPhotoWorkspaceProps) {
|
||||
const modeOptions: { key: ProductPhotoWorkspaceMode; label: string }[] = [
|
||||
{ key: "source", label: "实拍资料" },
|
||||
{ key: "ai-white-background", label: "AI 白底图" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 gap-1.5 overflow-hidden sm:gap-2 xl:grid-cols-[180px_minmax(540px,580px)_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-hidden sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-1">
|
||||
{modeOptions.map((option) => {
|
||||
const isActive = activeMode === option.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
data-testid={`product-photo-workspace-mode-${option.key}`}
|
||||
onClick={() => onSwitchMode(option.key)}
|
||||
className={`min-h-[32px] rounded-apple-lg px-3 text-[12px] font-apple-medium transition sm:text-[13px] ${
|
||||
isActive
|
||||
? "bg-apple-label-primary text-white"
|
||||
: "text-apple-label-secondary hover:bg-apple-bg-secondary/60 hover:text-apple-label-primary"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-1.5 overflow-hidden sm:gap-2 xl:grid-cols-[180px_minmax(540px,580px)_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
|
||||
<div className="border-b border-apple-separator px-2 py-1.5">
|
||||
<p className="text-[13px] font-apple-medium text-apple-label-primary sm:text-sm">
|
||||
款式分类
|
||||
@ -110,62 +145,71 @@ export function ProductPhotoWorkspace({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
|
||||
<div className="border-b border-apple-separator px-2 py-1.5">
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{PRODUCT_PHOTO_PANEL_ORDER.map((panelKey) => {
|
||||
const countLabel =
|
||||
panelKey === "nine-view"
|
||||
? `${workspaceData.filledSlotCount} / 9`
|
||||
: `${workspaceData.groupFiles[panelKey].length} 张`;
|
||||
const isActive = activePanel === panelKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={panelKey}
|
||||
type="button"
|
||||
data-testid={`product-photo-panel-tab-${panelKey}`}
|
||||
onClick={() => onSwitchPanel(panelKey)}
|
||||
className={`min-h-[30px] rounded-apple-lg border px-1.5 py-1 text-left transition sm:min-h-[34px] sm:px-2 ${
|
||||
isActive
|
||||
? "border-apple-label-primary bg-apple-label-primary text-white"
|
||||
: "border-apple-separator bg-apple-bg-secondary/20 text-apple-label-primary hover:bg-apple-bg-secondary/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-col justify-center gap-0.5">
|
||||
<span className="truncate text-[10px] font-apple-medium leading-4 sm:text-[11px]">
|
||||
{getProductPhotoPanelCompactTitle(panelKey)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-fit rounded-full px-1.5 py-0 text-[9px] leading-3 sm:text-[10px] ${
|
||||
isActive
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-apple-bg-primary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
{countLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden p-2.5">
|
||||
{activePanel === "nine-view" ? (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1 sm:overflow-hidden sm:pr-0">
|
||||
<div
|
||||
data-testid="product-photo-nine-view-grid"
|
||||
className="grid flex-none auto-rows-[116px] grid-cols-2 gap-2 sm:min-h-[220px] sm:flex-1 sm:auto-rows-auto sm:grid-cols-3 sm:grid-rows-3"
|
||||
>
|
||||
{PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => {
|
||||
const slotFile = workspaceData.slotFiles[slot.key];
|
||||
const isActive =
|
||||
selectedTarget.group === "nine-view" &&
|
||||
selectedTarget.slot === slot.key;
|
||||
{activeMode === "ai-white-background" ? (
|
||||
<div
|
||||
data-testid="product-photo-ai-white-background-workspace"
|
||||
className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20 p-2 xl:col-span-2"
|
||||
>
|
||||
{aiWhiteBackgroundContent}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
|
||||
<div className="border-b border-apple-separator px-2 py-1.5">
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{PRODUCT_PHOTO_PANEL_ORDER.map((panelKey) => {
|
||||
const countLabel =
|
||||
panelKey === "nine-view"
|
||||
? `${workspaceData.filledSlotCount} / 9`
|
||||
: `${workspaceData.groupFiles[panelKey].length} 张`;
|
||||
const isActive = activePanel === panelKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={panelKey}
|
||||
type="button"
|
||||
data-testid={`product-photo-panel-tab-${panelKey}`}
|
||||
onClick={() => onSwitchPanel(panelKey)}
|
||||
className={`min-h-[30px] rounded-apple-lg border px-1.5 py-1 text-left transition sm:min-h-[34px] sm:px-2 ${
|
||||
isActive
|
||||
? "border-apple-label-primary bg-apple-label-primary text-white"
|
||||
: "border-apple-separator bg-apple-bg-secondary/20 text-apple-label-primary hover:bg-apple-bg-secondary/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-col justify-center gap-0.5">
|
||||
<span className="truncate text-[10px] font-apple-medium leading-4 sm:text-[11px]">
|
||||
{getProductPhotoPanelCompactTitle(panelKey)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-fit rounded-full px-1.5 py-0 text-[9px] leading-3 sm:text-[10px] ${
|
||||
isActive
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-apple-bg-primary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
{countLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden p-2.5">
|
||||
{activePanel === "nine-view" ? (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1 sm:overflow-hidden sm:pr-0">
|
||||
<div
|
||||
data-testid="product-photo-nine-view-grid"
|
||||
className="grid flex-none auto-rows-[116px] grid-cols-2 gap-2 sm:min-h-[220px] sm:flex-1 sm:auto-rows-auto sm:grid-cols-3 sm:grid-rows-3"
|
||||
>
|
||||
{PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => {
|
||||
const slotFile = workspaceData.slotFiles[slot.key];
|
||||
const isActive =
|
||||
selectedTarget.group === "nine-view" &&
|
||||
selectedTarget.slot === slot.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -216,37 +260,37 @@ export function ProductPhotoWorkspace({
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{activePanelFiles.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`product-photo-empty-panel-upload-${activePanel}`}
|
||||
onClick={() => onOpenPanelUpload(activePanel)}
|
||||
disabled={isReadOnly}
|
||||
className="flex min-h-[140px] 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">
|
||||
当前还没有{activePanelTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
{isReadOnly
|
||||
? `当前还没有${activePanelTitle}资料。`
|
||||
: `点击这里或上方“${groupUploadLabel}”后,这里会显示图片缩略图列表。`}
|
||||
</p>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
data-testid="product-photo-detail-list"
|
||||
className="grid min-h-0 flex-1 gap-2 overflow-y-auto pr-1"
|
||||
>
|
||||
{activePanelFiles.map((file) => {
|
||||
const isActive =
|
||||
selectedTarget.group === activePanel &&
|
||||
selectedTarget.fileId === file.id;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{activePanelFiles.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`product-photo-empty-panel-upload-${activePanel}`}
|
||||
onClick={() => onOpenPanelUpload(activePanel)}
|
||||
disabled={isReadOnly}
|
||||
className="flex min-h-[140px] 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">
|
||||
当前还没有{activePanelTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
{isReadOnly
|
||||
? `当前还没有${activePanelTitle}资料。`
|
||||
: `点击这里或上方“${groupUploadLabel}”后,这里会显示图片缩略图列表。`}
|
||||
</p>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
data-testid="product-photo-detail-list"
|
||||
className="grid min-h-0 flex-1 gap-2 overflow-y-auto pr-1"
|
||||
>
|
||||
{activePanelFiles.map((file) => {
|
||||
const isActive =
|
||||
selectedTarget.group === activePanel &&
|
||||
selectedTarget.fileId === file.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -289,26 +333,29 @@ export function ProductPhotoWorkspace({
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20">
|
||||
<div className="border-b border-apple-separator px-3 py-1.5">
|
||||
<p className="truncate text-[13px] font-apple-medium text-apple-label-primary sm:text-sm">
|
||||
{previewTitle}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-apple-label-secondary sm:text-[11px]">
|
||||
{previewDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-2">
|
||||
{previewContent}
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20">
|
||||
<div className="border-b border-apple-separator px-3 py-1.5">
|
||||
<p className="truncate text-[13px] font-apple-medium text-apple-label-primary sm:text-sm">
|
||||
{previewTitle}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-apple-label-secondary sm:text-[11px]">
|
||||
{previewDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-2">
|
||||
{previewContent}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -52,6 +52,7 @@ export type ProductPhotoFileGroup =
|
||||
| "competitor-sample"
|
||||
| "competitor-disassembly";
|
||||
export type ProductPhotoGroup = "nine-view" | ProductPhotoFileGroup;
|
||||
export type ProductPhotoWorkspaceMode = "source" | "ai-white-background";
|
||||
export type ProductPhotoNineViewSlot =
|
||||
| "perspective"
|
||||
| "top"
|
||||
|
||||
@ -28,8 +28,9 @@ type ReviewSheetWorkspaceProps = ComponentProps<typeof ReviewSheetWorkspace>;
|
||||
|
||||
type ProductPhotoWorkspaceRendererProps = Omit<
|
||||
ProductPhotoWorkspaceProps,
|
||||
"previewContent"
|
||||
"aiWhiteBackgroundContent" | "previewContent"
|
||||
> & {
|
||||
renderAiWhiteBackgroundContent: () => ReactNode;
|
||||
renderPreviewContent: () => ReactNode;
|
||||
};
|
||||
|
||||
@ -65,7 +66,11 @@ export function buildDocumentWorkspaceRenderers({
|
||||
detection,
|
||||
reviewSheet,
|
||||
}: DocumentWorkspaceRendererOptions) {
|
||||
const { renderPreviewContent, ...productPhotoProps } = productPhoto;
|
||||
const {
|
||||
renderAiWhiteBackgroundContent,
|
||||
renderPreviewContent,
|
||||
...productPhotoProps
|
||||
} = productPhoto;
|
||||
|
||||
function renderQualityInspectionWorkspace() {
|
||||
return <QualityInspectionWorkspace {...qualityInspection} />;
|
||||
@ -79,6 +84,7 @@ export function buildDocumentWorkspaceRenderers({
|
||||
return (
|
||||
<ProductPhotoWorkspace
|
||||
{...productPhotoProps}
|
||||
aiWhiteBackgroundContent={renderAiWhiteBackgroundContent()}
|
||||
previewContent={renderPreviewContent()}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import CameraScannerModal from "@/components/mobile/CameraScannerModal";
|
||||
import NonIntegratedAiWhiteBackgroundModal from "../ai-white-background/NonIntegratedAiWhiteBackgroundModal";
|
||||
import {
|
||||
ImagePreviewDialog,
|
||||
SeriesComponentDetailsDialog,
|
||||
@ -36,15 +35,11 @@ type WorkspaceImagePreviewState =
|
||||
| { kind: "workspace-gallery" }
|
||||
| null;
|
||||
|
||||
type AiWhiteBackgroundModalProps = ComponentProps<
|
||||
typeof NonIntegratedAiWhiteBackgroundModal
|
||||
>;
|
||||
type CameraScannerDetectedHandler = ComponentProps<
|
||||
typeof CameraScannerModal
|
||||
>["onDetected"];
|
||||
|
||||
export function DetailOverlayDialogs({
|
||||
aiWhiteBackgroundProps,
|
||||
barcodeScannerSkuId,
|
||||
bomImagePreview,
|
||||
canGoToNextWorkspaceImage,
|
||||
@ -69,7 +64,6 @@ export function DetailOverlayDialogs({
|
||||
workspacePreviewDialogLabel,
|
||||
workspacePreviewDialogSrc,
|
||||
}: {
|
||||
aiWhiteBackgroundProps: AiWhiteBackgroundModalProps;
|
||||
barcodeScannerSkuId: string | null;
|
||||
bomImagePreview: ImagePreviewState;
|
||||
canGoToNextWorkspaceImage: boolean;
|
||||
@ -149,7 +143,6 @@ export function DetailOverlayDialogs({
|
||||
navigationLabel={workspacePreviewDialogLabel}
|
||||
/>
|
||||
) : null}
|
||||
<NonIntegratedAiWhiteBackgroundModal {...aiWhiteBackgroundProps} />
|
||||
{selectedSeriesComponentSku ? (
|
||||
<SeriesComponentDetailsDialog
|
||||
skuLabel={
|
||||
|
||||
@ -17,6 +17,9 @@ type ProductFactoryReopenField = ComponentProps<
|
||||
typeof ProductFactoryHeaderActions
|
||||
>["reopenField"];
|
||||
type ProductPhotoPanel = ComponentProps<typeof ProductPhotoHeaderActions>["activePanel"];
|
||||
type ProductPhotoWorkspaceMode = ComponentProps<
|
||||
typeof ProductPhotoHeaderActions
|
||||
>["activeMode"];
|
||||
type ProductPhotoSelection = ComponentProps<
|
||||
typeof ProductPhotoHeaderActions
|
||||
>["selectedTarget"];
|
||||
@ -31,6 +34,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
|
||||
activeInstallationAssetFiles,
|
||||
activeProductDataGroupFiles,
|
||||
activeProductPhotoDownloadFiles,
|
||||
activeProductPhotoMode,
|
||||
activeProductPhotoPanel,
|
||||
canDeleteSelectedReviewRound,
|
||||
canDownloadWorkspaceFiles,
|
||||
@ -135,6 +139,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
|
||||
activeInstallationAssetFiles: NonIntegratedUploadFile[];
|
||||
activeProductDataGroupFiles: NonIntegratedUploadFile[];
|
||||
activeProductPhotoDownloadFiles: NonIntegratedUploadFile[];
|
||||
activeProductPhotoMode: ProductPhotoWorkspaceMode;
|
||||
activeProductPhotoPanel: ProductPhotoPanel;
|
||||
canDeleteSelectedReviewRound: boolean;
|
||||
canDownloadWorkspaceFiles: boolean;
|
||||
@ -352,6 +357,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
|
||||
currentUploadInputRef={productPhotoCurrentUploadInputRef}
|
||||
detailUploadKey={detailUploadKey}
|
||||
activePanel={activeProductPhotoPanel}
|
||||
activeMode={activeProductPhotoMode}
|
||||
selectedTarget={selectedProductPhotoTarget}
|
||||
groupUploadLabel={currentProductPhotoGroupUploadLabel}
|
||||
currentUploadLabel={currentProductPhotoUploadLabel}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user