diff --git a/app/api/canvas/layout-replace/batches/[batchId]/route.ts b/app/api/canvas/layout-replace/batches/[batchId]/route.ts new file mode 100644 index 000000000..0d51c3a12 --- /dev/null +++ b/app/api/canvas/layout-replace/batches/[batchId]/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + getCanvasLayoutReplaceBatchActiveTaskId, + getCanvasLayoutReplaceBatchForUser, + markCanvasLayoutReplaceBatchItemsAdded, + retryCanvasLayoutReplaceBatchItem, +} from "@/lib/canvas/layout-replace-batch"; +import { + enqueueCanvasImageTask, + ensureCanvasImageTaskActive, +} from "@/lib/canvas/image-task-queue"; + +function normalizeUuid(value: unknown) { + const id = String(value || "").trim(); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + ? id + : ""; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ batchId: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + const batchId = normalizeUuid((await params).batchId); + if (!batchId) { + return NextResponse.json({ success: false, error: "批次ID无效" }, { status: 400 }); + } + + let batch = await getCanvasLayoutReplaceBatchForUser({ + batchId, + userId: session.user.id, + }); + if (!batch) { + return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 }); + } + const activeTaskId = getCanvasLayoutReplaceBatchActiveTaskId(batch); + if (activeTaskId) { + await ensureCanvasImageTaskActive({ + taskId: activeTaskId, + userId: session.user.id, + }); + batch = await getCanvasLayoutReplaceBatchForUser({ + batchId, + userId: session.user.id, + }); + } + + return NextResponse.json({ success: true, batch }); + } catch (error) { + console.error("[Canvas Layout Replace Batch][Detail] Error:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询批次失败", + }, + { status: 500 } + ); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ batchId: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + const batchId = normalizeUuid((await params).batchId); + const body = await request.json().catch(() => ({})); + if (body?.action === "retry") { + const itemId = normalizeUuid(body?.itemId); + if (!batchId || !itemId) { + return NextResponse.json( + { success: false, error: "缺少需要重试的结果" }, + { status: 400 } + ); + } + const taskId = await retryCanvasLayoutReplaceBatchItem({ + batchId, + itemId, + userId: session.user.id, + }); + if (!taskId) { + return NextResponse.json( + { success: false, error: "该结果当前无法重试" }, + { status: 409 } + ); + } + await enqueueCanvasImageTask(taskId); + return NextResponse.json({ success: true, taskId }); + } + + const items = Array.isArray(body?.items) + ? body.items + .map((item: unknown) => { + const record = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : {}; + return { + itemId: normalizeUuid(record.itemId), + canvasNodeId: String(record.canvasNodeId || "").trim(), + }; + }) + .filter( + (item: { itemId: string; canvasNodeId: string }) => + Boolean(item.itemId && item.canvasNodeId) + ) + : []; + if (!batchId || items.length === 0) { + return NextResponse.json( + { success: false, error: "缺少已添加的结果信息" }, + { status: 400 } + ); + } + const updated = await markCanvasLayoutReplaceBatchItemsAdded({ + batchId, + userId: session.user.id, + items, + }); + if (!updated) { + return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Canvas Layout Replace Batch][PATCH] Error:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "更新批次失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/canvas/layout-replace/batches/route.ts b/app/api/canvas/layout-replace/batches/route.ts new file mode 100644 index 000000000..2bbd27e09 --- /dev/null +++ b/app/api/canvas/layout-replace/batches/route.ts @@ -0,0 +1,201 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + CANVAS_LAYOUT_REPLACE_BATCH_LIMIT, + createCanvasLayoutReplaceBatch, + getCanvasLayoutReplaceBatchActiveTaskId, + getLatestCanvasLayoutReplaceBatchForUser, + type CanvasLayoutReplaceBatchSource, +} from "@/lib/canvas/layout-replace-batch"; +import { + enqueueCanvasImageTask, + ensureCanvasImageTaskActive, +} from "@/lib/canvas/image-task-queue"; +import { db } from "@/lib/db"; +import { canvasProject } from "@/lib/db/schema"; +import { getPointsSystemConfig } from "@/lib/db/queries"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { getImageToolCreditCost } from "@/lib/points/config"; + +export const maxDuration = 1200; + +function normalizeHttpUrl(value: unknown) { + const url = String(value || "").trim(); + return /^https?:\/\//i.test(url) ? url : ""; +} + +function normalizeUuid(value: unknown) { + const id = String(value || "").trim(); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + ? id + : ""; +} + +function normalizeSources(value: unknown): CanvasLayoutReplaceBatchSource[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const sources: CanvasLayoutReplaceBatchSource[] = []; + for (const item of value) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + const sourceImageUrl = normalizeHttpUrl(record.sourceImageUrl); + if (!sourceImageUrl || seen.has(sourceImageUrl)) continue; + seen.add(sourceImageUrl); + sources.push({ + sourceImageUrl, + sourceKind: record.sourceKind === "upload" ? "upload" : "canvas", + sourceNodeId: String(record.sourceNodeId || "").trim() || undefined, + sourceTitle: String(record.sourceTitle || "").trim().slice(0, 256) || undefined, + }); + if (sources.length >= CANVAS_LAYOUT_REPLACE_BATCH_LIMIT) break; + } + return sources; +} + +async function getOwnedProject(projectId: string, userId: string) { + const [project] = await db + .select({ id: canvasProject.id }) + .from(canvasProject) + .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) + .limit(1); + return project || null; +} + +export async function GET(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const projectId = normalizeUuid(searchParams.get("projectId")); + const sourceNodeId = String(searchParams.get("sourceNodeId") || "").trim(); + if (!projectId) { + return NextResponse.json( + { success: false, error: "缺少有效的画布项目" }, + { status: 400 } + ); + } + if (!(await getOwnedProject(projectId, session.user.id))) { + return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 }); + } + + let batch = await getLatestCanvasLayoutReplaceBatchForUser({ + projectId, + userId: session.user.id, + sourceNodeId: sourceNodeId || undefined, + }); + const activeTaskId = batch + ? getCanvasLayoutReplaceBatchActiveTaskId(batch) + : null; + if (activeTaskId) { + await ensureCanvasImageTaskActive({ + taskId: activeTaskId, + userId: session.user.id, + }); + batch = await getLatestCanvasLayoutReplaceBatchForUser({ + projectId, + userId: session.user.id, + sourceNodeId: sourceNodeId || undefined, + }); + } + + return NextResponse.json({ success: true, batch: batch || null }); + } catch (error) { + console.error("[Canvas Layout Replace Batch][GET] Error:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询批次失败", + }, + { status: 500 } + ); + } +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const projectId = normalizeUuid(body?.projectId); + const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl); + const sources = normalizeSources(body?.sources); + if (!projectId || !(await getOwnedProject(projectId, session.user.id))) { + return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 }); + } + if (sources.length === 0) { + return NextResponse.json( + { success: false, error: "请至少添加一张待处理图片" }, + { status: 400 } + ); + } + if (!referenceImageUrl) { + return NextResponse.json( + { success: false, error: "请上传目标模版图" }, + { status: 400 } + ); + } + + const pointsConfig = await getPointsSystemConfig(); + const perImageCost = getImageToolCreditCost(pointsConfig, "repaint"); + const totalCost = perImageCost * sources.length; + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: totalCost, + reason: "image_repaint", + modelId: "canvas-layout-replace-batch", + note: `批量排版替换 ${sources.length} 张`, + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + + const created = await createCanvasLayoutReplaceBatch({ + userId: session.user.id, + projectId, + sources, + referenceImageUrl, + supplementalPrompt: + typeof body?.supplementalPrompt === "string" + ? body.supplementalPrompt + : undefined, + imageSize: typeof body?.imageSize === "string" ? body.imageSize : "2K", + model: typeof body?.model === "string" ? body.model : undefined, + }); + await enqueueCanvasImageTask(created.firstTaskId); + + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume({ + note: `批量排版替换 ${sources.length} 张(${created.batchId})`, + }); + } catch (error) { + console.error("[Points] Failed to consume layout batch points:", error); + } + } + + return NextResponse.json({ + success: true, + batchId: created.batchId, + firstTaskId: created.firstTaskId, + sourceCount: sources.length, + pointsCost: totalCost, + }); + } catch (error) { + console.error("[Canvas Layout Replace Batch][POST] Error:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "创建批次失败", + }, + { status: 500 } + ); + } +} diff --git a/components/canvas/CanvasLayoutReplaceBatchDialog.tsx b/components/canvas/CanvasLayoutReplaceBatchDialog.tsx new file mode 100644 index 000000000..ec957ef34 --- /dev/null +++ b/components/canvas/CanvasLayoutReplaceBatchDialog.tsx @@ -0,0 +1,856 @@ +"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} +
+ + ) : ( + + )} +
+ ); + })} +
+ +
+ +