"use client"; import { Check, 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 { 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Progress } from "@/components/ui/progress"; import { Textarea } from "@/components/ui/textarea"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import { imageGenerationService, type CanvasLayoutReplaceBatchItemSnapshot, type CanvasLayoutReplaceBatchSnapshot, } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, ImageNodeData, Size } from "@/types/canvas"; const MAX_BATCH_SOURCES = 5; const ACTIVE_BATCH_STATUSES = new Set(["pending", "running"]); type LayoutReplaceSource = { id: string; imageUrl: string; kind: "canvas" | "upload"; nodeId?: string; title: string; }; 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 getReadyCanvasImage(node: CanvasNode): LayoutReplaceSource | null { if (node.type !== "image") return null; const data = node.data as ImageNodeData; const imageUrl = String(data.src || "").trim(); if (data.status !== "completed" || !isHttpImageUrl(imageUrl)) return null; return { id: `canvas:${node.id}`, imageUrl, kind: "canvas", nodeId: node.id, title: String(data.title || "画布图片").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 source = getReadyCanvasImage(selectedById.get(id) || primaryNode); if (!source || seen.has(source.imageUrl)) continue; seen.add(source.imageUrl); 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 sourceUploadInputRef = useRef(null); const referenceUploadInputRef = useRef(null); const seenSucceededItemIdsRef = useRef(new Set()); const [sources, setSources] = useState([]); const [referenceImageUrl, setReferenceImageUrl] = useState(""); const [supplementalPrompt, setSupplementalPrompt] = useState(""); const [batch, setBatch] = useState(null); const [selectedResultIds, setSelectedResultIds] = useState>(new Set()); const [isCanvasPickerOpen, setIsCanvasPickerOpen] = useState(false); 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(getReadyCanvasImage).filter(Boolean) as LayoutReplaceSource[], [allNodes] ); const sourceUrlSet = useMemo( () => new Set(sources.map((source) => source.imageUrl)), [sources] ); 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 initialSources = buildInitialSources(primaryNode, allNodes, 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; }; }, [allNodes, applyBatchSnapshot, open, primaryNode, projectId, selectedNodeIds]); 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 addCanvasSource = useCallback((source: LayoutReplaceSource) => { setSources((current) => { if (current.some((item) => item.imageUrl === source.imageUrl)) return current; if (current.length >= MAX_BATCH_SOURCES) { toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`); return current; } return [...current, source]; }); }, []); const removeSource = useCallback((sourceId: string) => { setSources((current) => current.filter((source) => source.id !== sourceId)); }, []); 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) => { const next = [...current]; for (const source of uploaded) { if (!next.some((item) => item.imageUrl === source.imageUrl)) next.push(source); } return next.slice(0, 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 (
AI 模版替换 {batch ? (
{getBatchStatusLabel(batch.status)} {terminalItemCount}/{batch.totalCount}
) : null}
最多选择五张图片,按照目标模版顺序生成并添加到画布
{isLoadingBatch ? (
) : (
{!batch ? (
{readyCanvasSources.map((source) => { const selected = sourceUrlSet.has(source.imageUrl); return ( ); })}
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}
) : ( )}
); })}