diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 9a382e306..314a5488d 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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) { + + {loginDialogElement} {projectLoadingBadge} diff --git a/components/canvas/CanvasAddImageDialog.tsx b/components/canvas/CanvasAddImageDialog.tsx new file mode 100644 index 000000000..030bb9403 --- /dev/null +++ b/components/canvas/CanvasAddImageDialog.tsx @@ -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; + 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 ( + + ); +} + +function SkuOptionCard({ + option, + onSelect, +}: { + option: AgentWorkflowMaterialSkuOption; + onSelect: (skuName: string) => void; +}) { + return ( + + ); +} + +export function CanvasAddImageDialog({ + materialSearchEnabled, + onAddImages, + onCreateBlankImage, + onOpenChange, + open, +}: CanvasAddImageDialogProps) { + const [query, setQuery] = useState(""); + const [queryMode, setQueryMode] = useState("spu"); + const [result, setResult] = useState(null); + const [selectedImageIds, setSelectedImageIds] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [errorText, setErrorText] = useState(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 ( + + + + + + 添加图片 + + + 添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。 + + + +
+
+
+ + + {materialSearchEnabled ? ( +
+ +
+ { + setQuery(event.target.value); + setErrorText(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleSearch(); + } + }} + placeholder={queryMode === "spu" ? "输入 SPU 查询 SKU" : "输入 SKU 查询白底图"} + value={query} + /> + {query ? ( + + ) : null} +
+ +
+ ) : null} +
+ {!materialSearchEnabled ? ( +
+ 风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。 +
+ ) : null} +
+ + {errorText ? ( +
+ {errorText} +
+ ) : null} + + {skuOptions.length > 0 ? ( +
+
+
+
选择 SKU
+
+ 命中 {skuOptions.length} 个 SKU,选择有白底图的 SKU 继续拉取图片。 +
+
+
+
+ {skuOptions.map((option) => ( + + ))} +
+
+ ) : null} + + {images.length > 0 ? ( +
+
+
+
+ 白底图素材 + {result?.matchedSku ? ` · ${result.matchedSku}` : ""} +
+
+ 共 {images.length} 张,已选 {selectedImages.length} 张。 +
+
+
+ + +
+
+
+ {images.map((image) => ( + + ))} +
+
+ ) : materialSearchEnabled && !isSearching && !skuOptions.length && result ? ( +
+ 当前没有可添加的白底图。 +
+ ) : null} +
+ + + + + +
+
+ ); +}