merge: sync ai white background workspace

This commit is contained in:
WuYongJie 2026-06-15 19:19:43 +08:00
commit e5175cabb0
10 changed files with 791 additions and 708 deletions

View File

@ -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 () => {

View File

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

View File

@ -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)]">

View File

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

View File

@ -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"

View File

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

View File

@ -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"

View File

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

View File

@ -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={

View File

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