"use client"; import { CircleAlert, ImagePlus, Images, LayoutTemplate, Loader2, Plus, RefreshCw, Trash2, Upload, } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; import { Textarea } from "@/components/ui/textarea"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import { getAvailableCanvasLayoutReplaceSources, getLayoutReplaceSourceIdentity, getReadyCanvasLayoutReplaceSource, type LayoutReplaceSource, mergeUniqueLayoutReplaceSources, } from "@/lib/canvas/layout-replace-source-selection"; import { imageGenerationService, type CanvasLayoutReplaceBatchItemSnapshot, type CanvasLayoutReplaceBatchSnapshot, } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, Size } from "@/types/canvas"; const MAX_BATCH_SOURCES = 5; const ACTIVE_BATCH_STATUSES = new Set(["pending", "running"]); type CanvasLayoutReplaceBatchDialogProps = { costLabel?: string | null; onOpenChange: (open: boolean) => void; open: boolean; primaryNode: CanvasNode; }; function isHttpImageUrl(value: unknown) { return /^https?:\/\//i.test(String(value || "").trim()); } function buildInitialSources( primaryNode: CanvasNode, nodes: CanvasNode[], selectedNodeIds: string[] ) { const selectedById = new Map(nodes.map((node) => [node.id, node])); const orderedIds = [primaryNode.id, ...selectedNodeIds.filter((id) => id !== primaryNode.id)]; const sources: LayoutReplaceSource[] = []; const seen = new Set(); for (const id of orderedIds) { const node = id === primaryNode.id ? primaryNode : selectedById.get(id); if (!node) continue; const source = getReadyCanvasLayoutReplaceSource(node); if (!source) continue; const identity = getLayoutReplaceSourceIdentity(source); if (seen.has(identity)) continue; seen.add(identity); sources.push(source); if (sources.length >= MAX_BATCH_SOURCES) break; } return sources; } function sourcesFromBatch(batch: CanvasLayoutReplaceBatchSnapshot) { return batch.items.map((item) => ({ id: item.sourceNodeId ? `canvas:${item.sourceNodeId}` : `upload:${item.id}`, imageUrl: item.sourceImageUrl, kind: item.sourceKind === "upload" ? ("upload" as const) : ("canvas" as const), nodeId: item.sourceNodeId || undefined, title: item.sourceTitle || `图片 ${item.position}`, })); } function getBatchStatusLabel(status: string) { if (status === "succeeded") return "全部完成"; if (status === "partial_failed") return "部分失败"; if (status === "failed") return "处理失败"; if (status === "running") return "处理中"; return "排队中"; } function getItemStatusLabel(item: CanvasLayoutReplaceBatchItemSnapshot) { if (item.status === "succeeded") return item.addedToCanvas ? "已加入画布" : "生成完成"; if (item.status === "failed") return "生成失败"; if (item.status === "running") return "正在生成"; return "排队中"; } async function resolveImageSize(url: string, fallback: Size) { if (typeof window === "undefined") return fallback; return new Promise((resolve) => { const image = new window.Image(); const timeout = window.setTimeout(() => resolve(fallback), 6000); image.onload = () => { window.clearTimeout(timeout); const naturalWidth = image.naturalWidth || fallback.width; const naturalHeight = image.naturalHeight || fallback.height; const width = Math.max(180, Math.min(720, fallback.width || 360)); resolve({ width, height: Math.max(120, Math.min(1000, Math.round((width * naturalHeight) / naturalWidth))), }); }; image.onerror = () => { window.clearTimeout(timeout); resolve(fallback); }; image.src = buildImageProxyUrl(url, { delivery: "proxy" }); }); } export function CanvasLayoutReplaceBatchDialog({ costLabel, onOpenChange, open, primaryNode, }: CanvasLayoutReplaceBatchDialogProps) { const allNodes = useCanvasStore((state) => state.nodes); const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds); const projectId = useCanvasStore((state) => state.projectId); const addNode = useCanvasStore((state) => state.addNode); const selectNodes = useCanvasStore((state) => state.selectNodes); const canvasImageSelectionSession = useCanvasStore( (state) => state.canvasImageSelectionSession ); const startCanvasImageSelection = useCanvasStore( (state) => state.startCanvasImageSelection ); const endCanvasImageSelection = useCanvasStore( (state) => state.endCanvasImageSelection ); const sourceUploadInputRef = useRef(null); const referenceUploadInputRef = useRef(null); const seenSucceededItemIdsRef = useRef(new Set()); const [sources, setSources] = useState(() => buildInitialSources(primaryNode, allNodes, selectedNodeIds) ); const [referenceImageUrl, setReferenceImageUrl] = useState(""); const [supplementalPrompt, setSupplementalPrompt] = useState(""); const [batch, setBatch] = useState(null); const [selectedResultIds, setSelectedResultIds] = useState>(new Set()); const [isLoadingBatch, setIsLoadingBatch] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isUploadingSources, setIsUploadingSources] = useState(false); const [isUploadingReference, setIsUploadingReference] = useState(false); const [isAddingResults, setIsAddingResults] = useState(false); const [retryingItemId, setRetryingItemId] = useState(null); const readyCanvasSources = useMemo( () => allNodes .map(getReadyCanvasLayoutReplaceSource) .filter(Boolean) as LayoutReplaceSource[], [allNodes] ); const activeCanvasImageSelectionSession = canvasImageSelectionSession?.ownerNodeId === primaryNode.id ? canvasImageSelectionSession : null; const successfulItems = useMemo( () => batch?.items.filter((item) => item.status === "succeeded" && item.resultImageUrl) || [], [batch] ); const batchIsActive = Boolean(batch && ACTIVE_BATCH_STATUSES.has(batch.status)); const terminalItemCount = batch ? batch.items.filter((item) => item.status === "succeeded" || item.status === "failed").length : 0; const batchProgress = batch ? Math.round((terminalItemCount / Math.max(1, batch.totalCount)) * 100) : 0; const applyBatchSnapshot = useCallback((nextBatch: CanvasLayoutReplaceBatchSnapshot) => { setBatch(nextBatch); setSources(sourcesFromBatch(nextBatch)); setReferenceImageUrl(nextBatch.referenceImageUrl); setSupplementalPrompt(nextBatch.supplementalPrompt || ""); const newlySucceeded = nextBatch.items .filter( (item) => item.status === "succeeded" && !item.addedToCanvas && !seenSucceededItemIdsRef.current.has(item.id) ) .map((item) => item.id); if (newlySucceeded.length > 0) { setSelectedResultIds((current) => new Set([...current, ...newlySucceeded])); } nextBatch.items .filter((item) => item.status === "succeeded") .forEach((item) => seenSucceededItemIdsRef.current.add(item.id)); }, []); const loadBatch = useCallback( async (batchId: string) => { const result = await imageGenerationService.getLayoutReplaceBatch(batchId); if (result.success && result.data?.batch) { applyBatchSnapshot(result.data.batch); return result.data.batch; } return null; }, [applyBatchSnapshot] ); useEffect(() => { if (!open) return; const currentCanvas = useCanvasStore.getState(); const currentPrimaryNode = currentCanvas.nodes.find((node) => node.id === primaryNode.id) || primaryNode; const initialSources = buildInitialSources( currentPrimaryNode, currentCanvas.nodes, currentCanvas.selectedNodeIds ); setSources(initialSources); setSelectedResultIds(new Set()); seenSucceededItemIdsRef.current = new Set(); if (!projectId) { setBatch(null); return; } let cancelled = false; setIsLoadingBatch(true); void imageGenerationService .getLatestLayoutReplaceBatch({ projectId, sourceNodeId: primaryNode.id }) .then((result) => { if (cancelled) return; if (result.success && result.data?.batch) { applyBatchSnapshot(result.data.batch); } else { setBatch(null); } }) .finally(() => { if (!cancelled) setIsLoadingBatch(false); }); return () => { cancelled = true; }; }, [applyBatchSnapshot, open, primaryNode.id, projectId]); useEffect( () => () => { endCanvasImageSelection(primaryNode.id); }, [endCanvasImageSelection, primaryNode.id] ); useEffect(() => { if (!activeCanvasImageSelectionSession) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); endCanvasImageSelection(primaryNode.id); }; window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); }, [activeCanvasImageSelectionSession, endCanvasImageSelection, primaryNode.id]); useEffect(() => { if (!open || !batch?.id || !batchIsActive) return; const interval = window.setInterval(() => { void loadBatch(batch.id); }, 2500); return () => window.clearInterval(interval); }, [batch?.id, batchIsActive, loadBatch, open]); const startNewBatchDraft = useCallback(() => { setBatch(null); setSelectedResultIds(new Set()); seenSucceededItemIdsRef.current = new Set(); setSources(buildInitialSources(primaryNode, allNodes, selectedNodeIds)); }, [allNodes, primaryNode, selectedNodeIds]); const removeSource = useCallback((sourceId: string) => { setSources((current) => current.filter((source) => source.id !== sourceId)); }, []); const startCanvasSourceSelection = useCallback(() => { if (canvasImageSelectionSession) return; const remainingCount = MAX_BATCH_SOURCES - sources.length; if (remainingCount <= 0) { toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`); return; } const availableSources = getAvailableCanvasLayoutReplaceSources( readyCanvasSources, sources ); if (availableSources.length === 0) { toast.info("画布中暂无其他可添加的图片"); return; } startCanvasImageSelection({ ownerNodeId: primaryNode.id, allowedNodeIds: availableSources.flatMap((source) => source.nodeId ? [source.nodeId] : [] ), maxCount: remainingCount, }); }, [ canvasImageSelectionSession, primaryNode.id, readyCanvasSources, sources, startCanvasImageSelection, ]); const cancelCanvasSourceSelection = useCallback(() => { endCanvasImageSelection(primaryNode.id); }, [endCanvasImageSelection, primaryNode.id]); const confirmCanvasSourceSelection = useCallback(() => { if (!activeCanvasImageSelectionSession) return; const selectedIds = new Set(activeCanvasImageSelectionSession.selectedNodeIds); const selectedSources = readyCanvasSources.filter( (source) => source.nodeId && selectedIds.has(source.nodeId) ); if (selectedSources.length === 0) return; setSources((current) => { return mergeUniqueLayoutReplaceSources( current, selectedSources, MAX_BATCH_SOURCES ); }); endCanvasImageSelection(primaryNode.id); toast.success(`已从画布添加 ${selectedSources.length} 张图片`); }, [ activeCanvasImageSelectionSession, endCanvasImageSelection, primaryNode.id, readyCanvasSources, ]); const handleSourceUpload = useCallback( async (event: React.ChangeEvent) => { const files = Array.from(event.currentTarget.files || []).filter((file) => file.type.startsWith("image/") ); event.currentTarget.value = ""; const remaining = Math.max(0, MAX_BATCH_SOURCES - sources.length); if (remaining === 0 || files.length === 0) return; const selectedFiles = files.slice(0, remaining); if (selectedFiles.length < files.length) { toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`); } setIsUploadingSources(true); try { const uploaded: LayoutReplaceSource[] = []; for (const file of selectedFiles) { const result = await imageGenerationService.uploadImage(file); if (!result.success || !result.data?.url) { throw new Error(result.error || `${file.name} 上传失败`); } uploaded.push({ id: `upload:${crypto.randomUUID()}`, imageUrl: result.data.url, kind: "upload", title: file.name.replace(/\.[^.]+$/, "") || "上传图片", }); } setSources((current) => { return mergeUniqueLayoutReplaceSources( current, uploaded, MAX_BATCH_SOURCES ); }); toast.success(`已上传 ${uploaded.length} 张图片`); } catch (error) { toast.error(error instanceof Error ? error.message : "图片上传失败"); } finally { setIsUploadingSources(false); } }, [sources.length] ); const handleReferenceUpload = useCallback(async (event: React.ChangeEvent) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (!file || !file.type.startsWith("image/")) return; setIsUploadingReference(true); try { const result = await imageGenerationService.uploadImage(file); if (!result.success || !result.data?.url) { throw new Error(result.error || "目标模版图上传失败"); } setReferenceImageUrl(result.data.url); toast.success("目标模版图已上传"); } catch (error) { toast.error(error instanceof Error ? error.message : "目标模版图上传失败"); } finally { setIsUploadingReference(false); } }, []); const handleSubmit = useCallback(async () => { if (!projectId) { toast.error("当前画布尚未保存,无法创建批次"); return; } if (sources.length === 0) { toast.error("请至少添加一张待处理图片"); return; } if (!isHttpImageUrl(referenceImageUrl)) { toast.error("请上传目标模版图"); return; } if (isSubmitting || isUploadingSources || isUploadingReference) return; setIsSubmitting(true); try { const result = await imageGenerationService.createLayoutReplaceBatch({ projectId, sources: sources.map((source) => ({ sourceImageUrl: source.imageUrl, sourceKind: source.kind, sourceNodeId: source.nodeId, sourceTitle: source.title, })), referenceImageUrl, supplementalPrompt, imageSize: "2K", }); if (!result.success || !result.data?.batchId) { throw new Error(result.error || "创建批次失败"); } const nextBatch = await loadBatch(result.data.batchId); if (!nextBatch) throw new Error("批次已创建,但状态读取失败"); toast.success(`已提交 ${sources.length} 张图片,正在顺序处理`); } catch (error) { toast.error(error instanceof Error ? error.message : "创建批次失败"); } finally { setIsSubmitting(false); } }, [ isSubmitting, isUploadingReference, isUploadingSources, loadBatch, projectId, referenceImageUrl, sources, supplementalPrompt, ]); const addItemsToCanvas = useCallback( async (items: CanvasLayoutReplaceBatchItemSnapshot[]) => { if (!batch || items.length === 0 || isAddingResults) return; setIsAddingResults(true); try { const created: Array<{ itemId: string; canvasNodeId: string }> = []; for (const [index, item] of items.entries()) { if (!item.resultImageUrl || item.addedToCanvas) continue; const sourceNode = allNodes.find((candidate) => candidate.id === item.sourceNodeId); const anchor = sourceNode || primaryNode; const resultSize = await resolveImageSize(item.resultImageUrl, anchor.size); const canvasNodeId = addNode({ type: "image", position: { x: anchor.position.x + anchor.size.width + 20, y: anchor.position.y + index * 24, }, size: resultSize, data: { src: item.resultImageUrl, thumbnailUrl: item.resultThumbnailUrl || item.resultImageUrl, status: "completed", prompt: "批量排版替换结果", title: `${item.sourceTitle || `图片 ${item.position}`}-排版替换`, originalSrc: item.sourceImageUrl, model: item.resultModelId || undefined, metadata: { editMode: "layout-replace", batchId: batch.id, batchItemId: item.id, taskId: item.taskId, sourceNodeId: item.sourceNodeId, layoutReferenceUrl: batch.referenceImageUrl, layoutReplaceCompletedAt: new Date().toISOString(), }, }, connections: item.sourceNodeId ? [item.sourceNodeId] : [], }); created.push({ itemId: item.id, canvasNodeId }); } if (created.length === 0) return; selectNodes(created.map((item) => item.canvasNodeId)); const marked = await imageGenerationService.markLayoutReplaceBatchItemsAdded({ batchId: batch.id, items: created, }); if (!marked.success) { toast.warning("结果已加入画布,但同步批次状态失败"); } await loadBatch(batch.id); setSelectedResultIds((current) => { const next = new Set(current); created.forEach((item) => next.delete(item.itemId)); return next; }); toast.success(`已将 ${created.length} 张结果加入画布`); } catch (error) { toast.error(error instanceof Error ? error.message : "添加到画布失败"); } finally { setIsAddingResults(false); } }, [addNode, allNodes, batch, isAddingResults, loadBatch, primaryNode, selectNodes] ); const selectedSuccessfulItems = successfulItems.filter( (item) => selectedResultIds.has(item.id) && !item.addedToCanvas ); const retryItem = useCallback( async (item: CanvasLayoutReplaceBatchItemSnapshot) => { if (!batch || batchIsActive || retryingItemId) return; setRetryingItemId(item.id); try { const result = await imageGenerationService.retryLayoutReplaceBatchItem({ batchId: batch.id, itemId: item.id, }); if (!result.success) throw new Error(result.error || "重试失败"); await loadBatch(batch.id); toast.success("已重新加入生成队列"); } catch (error) { toast.error(error instanceof Error ? error.message : "重试失败"); } finally { setRetryingItemId(null); } }, [batch, batchIsActive, loadBatch, retryingItemId] ); return ( <> { if (!activeCanvasImageSelectionSession) onOpenChange(nextOpen); }} open={open && !activeCanvasImageSelectionSession} >
AI 模版替换 {batch ? (
{getBatchStatusLabel(batch.status)} {terminalItemCount}/{batch.totalCount}
) : null}
最多选择五张图片,按照目标模版顺序生成并添加到画布
{isLoadingBatch ? (
) : (
{!batch ? (
void handleSourceUpload(event)} ref={sourceUploadInputRef} type="file" />
) : null}
{Array.from({ length: MAX_BATCH_SOURCES }, (_, index) => { const source = sources[index]; return (
{index + 1} {source ? ( <> {source.title} {!batch && index > 0 ? ( ) : null}
{source.title}
) : ( )}
); })}