feat: add canvas material image search
This commit is contained in:
parent
3a2a7f2490
commit
b78c77a790
@ -80,6 +80,10 @@ import {
|
||||
AgentWorkflowPanelDarkV2,
|
||||
} from "@/components/agents/AgentWorkflowPanelDarkV2";
|
||||
import { type AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanelNew";
|
||||
import {
|
||||
CanvasAddImageDialog,
|
||||
type CanvasAddImageDialogImage,
|
||||
} from "@/components/canvas/CanvasAddImageDialog";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -1716,6 +1720,48 @@ function getAllResultUrlsFromCollections(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCanvasImageDisplaySize(url: string) {
|
||||
return new Promise<{
|
||||
height: number;
|
||||
originalHeight: number;
|
||||
originalWidth: number;
|
||||
width: number;
|
||||
}>((resolve) => {
|
||||
if (typeof window === "undefined" || !url) {
|
||||
resolve({
|
||||
height: 320,
|
||||
originalHeight: 0,
|
||||
originalWidth: 0,
|
||||
width: 320,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new window.Image();
|
||||
image.onload = () => {
|
||||
const originalWidth = image.naturalWidth || 320;
|
||||
const originalHeight = image.naturalHeight || 320;
|
||||
const maxSide = 360;
|
||||
const scale = Math.min(1, maxSide / Math.max(originalWidth, originalHeight));
|
||||
resolve({
|
||||
height: Math.max(120, Math.round(originalHeight * scale)),
|
||||
originalHeight,
|
||||
originalWidth,
|
||||
width: Math.max(120, Math.round(originalWidth * scale)),
|
||||
});
|
||||
};
|
||||
image.onerror = () => {
|
||||
resolve({
|
||||
height: 320,
|
||||
originalHeight: 0,
|
||||
originalWidth: 0,
|
||||
width: 320,
|
||||
});
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
// 使用 useParams 确保客户端导航时能正确获取更新的 id
|
||||
const routeParams = useParams();
|
||||
@ -1795,6 +1841,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const safariGestureScaleRef = useRef(1);
|
||||
|
||||
@ -2113,6 +2160,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
);
|
||||
const isWorkflowMaterialStepEnabled =
|
||||
workflowPageFeatureSettings.materialStepEnabled !== false;
|
||||
const isCanvasAddImageMaterialSearchEnabled =
|
||||
isAgentWorkspaceEnabled && isWorkflowMaterialStepEnabled;
|
||||
const isBatchProcessingEnabled =
|
||||
workflowPageFeatureSettings.batchProcessingEnabled !== false &&
|
||||
sceneFusionConfigData?.enabled !== false;
|
||||
@ -6759,16 +6808,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
});
|
||||
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
|
||||
|
||||
// 添加图片节点
|
||||
const handleAddImageNode = useCallback(() => {
|
||||
if (isVideoMode) {
|
||||
handleAddWorkflowNode("input-image");
|
||||
return;
|
||||
}
|
||||
|
||||
const handleCreateBlankImageNode = useCallback(() => {
|
||||
const origin = getVisibleCanvasOrigin();
|
||||
createCanvasNode({
|
||||
type: "image",
|
||||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||||
position: {
|
||||
x: origin.x + 40 + Math.random() * 80,
|
||||
y: origin.y + 40 + Math.random() * 80,
|
||||
},
|
||||
size: { width: 300, height: 300 },
|
||||
data: {
|
||||
src: "",
|
||||
@ -6778,7 +6825,77 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
|
||||
}, [createCanvasNode, getVisibleCanvasOrigin]);
|
||||
|
||||
const handleAddMaterialImagesToCanvas = useCallback(
|
||||
async (images: CanvasAddImageDialogImage[]) => {
|
||||
const validImages = images.filter((image) =>
|
||||
String(image.imageUrl || "").trim()
|
||||
);
|
||||
if (!validImages.length) return;
|
||||
|
||||
const origin = getVisibleCanvasOrigin();
|
||||
const columns = Math.min(3, Math.max(1, validImages.length));
|
||||
const cellWidth = 388;
|
||||
const cellHeight = 388;
|
||||
const createdNodeIds: string[] = [];
|
||||
|
||||
for (const [index, image] of validImages.entries()) {
|
||||
const dimensions = await resolveCanvasImageDisplaySize(image.imageUrl);
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const nodeId = createCanvasNode(
|
||||
{
|
||||
type: "image",
|
||||
position: {
|
||||
x: origin.x + 40 + column * cellWidth,
|
||||
y: origin.y + 40 + row * cellHeight,
|
||||
},
|
||||
size: {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
},
|
||||
data: {
|
||||
src: image.imageUrl,
|
||||
assetId: image.assetId || undefined,
|
||||
status: "completed",
|
||||
title: image.label || image.fileName || "白底图",
|
||||
prompt: image.fileName || image.label || "素材库白底图",
|
||||
metadata: {
|
||||
materialFileName: image.fileName,
|
||||
materialGroup: image.group,
|
||||
materialImageId: image.id,
|
||||
materialKey: image.key,
|
||||
materialMimeType: image.mimeType || undefined,
|
||||
originalHeight: dimensions.originalHeight,
|
||||
originalWidth: dimensions.originalWidth,
|
||||
source: "agent-workflow-material-search",
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
},
|
||||
{ selectNewNode: false }
|
||||
);
|
||||
createdNodeIds.push(nodeId);
|
||||
}
|
||||
|
||||
if (createdNodeIds.length > 0) {
|
||||
selectNodes(createdNodeIds);
|
||||
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
|
||||
}
|
||||
},
|
||||
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
||||
);
|
||||
|
||||
// 添加图片节点
|
||||
const handleAddImageNode = useCallback(() => {
|
||||
if (isVideoMode) {
|
||||
handleAddWorkflowNode("input-image");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddImageDialogOpen(true);
|
||||
}, [handleAddWorkflowNode, isVideoMode]);
|
||||
|
||||
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
|
||||
if (isVideoMode) return;
|
||||
@ -11287,6 +11404,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CanvasAddImageDialog
|
||||
materialSearchEnabled={isCanvasAddImageMaterialSearchEnabled}
|
||||
onAddImages={handleAddMaterialImagesToCanvas}
|
||||
onCreateBlankImage={handleCreateBlankImageNode}
|
||||
onOpenChange={setIsAddImageDialogOpen}
|
||||
open={isAddImageDialogOpen}
|
||||
/>
|
||||
|
||||
{loginDialogElement}
|
||||
{projectLoadingBadge}
|
||||
</div>
|
||||
|
||||
439
components/canvas/CanvasAddImageDialog.tsx
Normal file
439
components/canvas/CanvasAddImageDialog.tsx
Normal file
@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AgentWorkflowMaterialImage,
|
||||
AgentWorkflowMaterialQueryType,
|
||||
AgentWorkflowMaterialSearchResult,
|
||||
AgentWorkflowMaterialSkuOption,
|
||||
} from "@/types/agent";
|
||||
|
||||
export type CanvasAddImageDialogImage = AgentWorkflowMaterialImage;
|
||||
|
||||
interface CanvasAddImageDialogProps {
|
||||
materialSearchEnabled: boolean;
|
||||
onAddImages: (images: CanvasAddImageDialogImage[]) => Promise<void> | void;
|
||||
onCreateBlankImage: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
function getMaterialSearchError(payload: any, fallback = "搜索失败") {
|
||||
const error = payload?.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (error?.message) return String(error.message);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeImages(result?: AgentWorkflowMaterialSearchResult | null) {
|
||||
return Array.isArray(result?.images)
|
||||
? result.images.filter((image) => String(image.imageUrl || "").trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
function MaterialImageCard({
|
||||
image,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
image: AgentWorkflowMaterialImage;
|
||||
selected: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-xl border bg-background text-left transition-all",
|
||||
selected
|
||||
? "border-primary shadow-[0_0_0_1px_hsl(var(--primary)/0.25)]"
|
||||
: "border-border hover:border-primary/45"
|
||||
)}
|
||||
onClick={() => onToggle(image.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted/30">
|
||||
<AgentPreviewImage
|
||||
alt={image.label || image.fileName || "白底图"}
|
||||
className="h-full w-full object-contain"
|
||||
delivery="proxy"
|
||||
previewQuality={76}
|
||||
previewWidth={520}
|
||||
src={image.previewUrl || image.imageUrl}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full border text-xs shadow-sm transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-white/70 bg-black/35 text-white opacity-80 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{selected ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{image.label || image.fileName || "白底图"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{image.group === "fixed" ? "固定角度" : "补充图"}</span>
|
||||
<span className="truncate">{image.fileName || image.key}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkuOptionCard({
|
||||
option,
|
||||
onSelect,
|
||||
}: {
|
||||
option: AgentWorkflowMaterialSkuOption;
|
||||
onSelect: (skuName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border bg-background p-3 text-left transition-colors",
|
||||
option.hasWhiteBackground
|
||||
? "hover:border-primary/55 hover:bg-muted/30"
|
||||
: "cursor-not-allowed opacity-55"
|
||||
)}
|
||||
disabled={!option.hasWhiteBackground}
|
||||
onClick={() => onSelect(option.skuName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="h-14 w-14 overflow-hidden rounded-lg border bg-muted/40">
|
||||
{option.previewImageUrl ? (
|
||||
<AgentPreviewImage
|
||||
alt={option.previewFileName || option.skuName}
|
||||
className="h-full w-full object-cover"
|
||||
previewQuality={72}
|
||||
previewWidth={180}
|
||||
src={option.previewImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{option.skuName}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{option.hasWhiteBackground
|
||||
? `${option.whiteBackgroundCount} 张白底图`
|
||||
: "暂无白底图"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasAddImageDialog({
|
||||
materialSearchEnabled,
|
||||
onAddImages,
|
||||
onCreateBlankImage,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: CanvasAddImageDialogProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [queryMode, setQueryMode] = useState<AgentWorkflowMaterialQueryType>("spu");
|
||||
const [result, setResult] = useState<AgentWorkflowMaterialSearchResult | null>(null);
|
||||
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
|
||||
const images = useMemo(() => normalizeImages(result), [result]);
|
||||
const skuOptions = Array.isArray(result?.skuOptions) ? result.skuOptions : [];
|
||||
const selectedImages = useMemo(
|
||||
() => images.filter((image) => selectedImageIds.includes(image.id)),
|
||||
[images, selectedImageIds]
|
||||
);
|
||||
|
||||
const handleSearch = async (nextQuery?: string, nextMode?: AgentWorkflowMaterialQueryType) => {
|
||||
if (!materialSearchEnabled) return;
|
||||
const keyword = String(nextQuery ?? query).trim();
|
||||
const mode = nextMode || queryMode;
|
||||
if (!keyword) {
|
||||
setErrorText("请输入 SPU 或 SKU");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setErrorText(null);
|
||||
const searchParams = new URLSearchParams({
|
||||
keyword,
|
||||
source: "primary",
|
||||
queryType: mode,
|
||||
appearanceRecognitionEnabled: "false",
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/agent-workflow/material-search?${searchParams.toString()}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.success !== true || !payload?.data) {
|
||||
throw new Error(getMaterialSearchError(payload));
|
||||
}
|
||||
setResult(payload.data);
|
||||
setSelectedImageIds(
|
||||
Array.isArray(payload.data?.recommendedImageIds)
|
||||
? payload.data.recommendedImageIds.filter((id: string) =>
|
||||
payload.data.images?.some((image: AgentWorkflowMaterialImage) => image.id === id)
|
||||
)
|
||||
: []
|
||||
);
|
||||
} catch (error) {
|
||||
setResult(null);
|
||||
setSelectedImageIds([]);
|
||||
setErrorText(error instanceof Error ? error.message : "搜索失败");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSku = (skuName: string) => {
|
||||
setQuery(skuName);
|
||||
setQueryMode("sku");
|
||||
void handleSearch(skuName, "sku");
|
||||
};
|
||||
|
||||
const handleToggleImage = (imageId: string) => {
|
||||
setSelectedImageIds((current) =>
|
||||
current.includes(imageId)
|
||||
? current.filter((id) => id !== imageId)
|
||||
: [...current, imageId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddSelected = async () => {
|
||||
if (!selectedImages.length) return;
|
||||
try {
|
||||
setIsAdding(true);
|
||||
await onAddImages(selectedImages);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 pt-5 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
添加图片
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto px-6 py-5">
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
className="h-11 justify-start gap-2 rounded-xl"
|
||||
onClick={() => {
|
||||
onCreateBlankImage();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
空白图片节点
|
||||
</Button>
|
||||
|
||||
{materialSearchEnabled ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row">
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setQueryMode(value === "sku" ? "sku" : "spu")
|
||||
}
|
||||
value={queryMode}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-full rounded-xl sm:w-[118px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="spu">SPU</SelectItem>
|
||||
<SelectItem value="sku">SKU</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
className="h-11 rounded-xl pr-10"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setErrorText(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={queryMode === "spu" ? "输入 SPU 查询 SKU" : "输入 SKU 查询白底图"}
|
||||
value={query}
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setResult(null);
|
||||
setSelectedImageIds([]);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
className="h-11 gap-2 rounded-xl"
|
||||
disabled={isSearching}
|
||||
onClick={() => void handleSearch()}
|
||||
type="button"
|
||||
>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!materialSearchEnabled ? (
|
||||
<div className="mt-3 rounded-xl border border-dashed bg-background/70 px-3 py-2 text-muted-foreground text-xs">
|
||||
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorText ? (
|
||||
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive text-sm">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{skuOptions.length > 0 ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">选择 SKU</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
命中 {skuOptions.length} 个 SKU,选择有白底图的 SKU 继续拉取图片。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{skuOptions.map((option) => (
|
||||
<SkuOptionCard
|
||||
key={`${option.skuName}-${option.skuId || "sku"}`}
|
||||
onSelect={handleSelectSku}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{images.length > 0 ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
白底图素材
|
||||
{result?.matchedSku ? ` · ${result.matchedSku}` : ""}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
共 {images.length} 张,已选 {selectedImages.length} 张。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setSelectedImageIds(images.map((image) => image.id))}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedImageIds.length}
|
||||
onClick={() => setSelectedImageIds([])}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{images.map((image) => (
|
||||
<MaterialImageCard
|
||||
image={image}
|
||||
key={image.id}
|
||||
onToggle={handleToggleImage}
|
||||
selected={selectedImageIds.includes(image.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : materialSearchEnabled && !isSearching && !skuOptions.length && result ? (
|
||||
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-2xl border border-dashed text-muted-foreground text-sm">
|
||||
当前没有可添加的白底图。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!selectedImages.length || isAdding}
|
||||
onClick={() => void handleAddSelected()}
|
||||
type="button"
|
||||
>
|
||||
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <ImagePlus className="h-4 w-4" />}
|
||||
添加所选{selectedImages.length ? ` (${selectedImages.length})` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user