954 lines
38 KiB
TypeScript
954 lines
38 KiB
TypeScript
"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<string>();
|
||
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<Size>((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<HTMLInputElement>(null);
|
||
const referenceUploadInputRef = useRef<HTMLInputElement>(null);
|
||
const seenSucceededItemIdsRef = useRef(new Set<string>());
|
||
const [sources, setSources] = useState<LayoutReplaceSource[]>(() =>
|
||
buildInitialSources(primaryNode, allNodes, selectedNodeIds)
|
||
);
|
||
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
||
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
||
const [batch, setBatch] = useState<CanvasLayoutReplaceBatchSnapshot | null>(null);
|
||
const [selectedResultIds, setSelectedResultIds] = useState<Set<string>>(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<string | null>(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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<>
|
||
<Dialog
|
||
onOpenChange={(nextOpen) => {
|
||
if (!activeCanvasImageSelectionSession) onOpenChange(nextOpen);
|
||
}}
|
||
open={open && !activeCanvasImageSelectionSession}
|
||
>
|
||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
|
||
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<DialogTitle className="flex items-center gap-2 text-base">
|
||
<LayoutTemplate className="h-4 w-4" />
|
||
AI 模版替换
|
||
</DialogTitle>
|
||
{batch ? (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>{getBatchStatusLabel(batch.status)}</span>
|
||
<span>{terminalItemCount}/{batch.totalCount}</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<DialogDescription className="sr-only">
|
||
最多选择五张图片,按照目标模版顺序生成并添加到画布
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||
{isLoadingBatch ? (
|
||
<div className="flex min-h-[360px] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<div className="space-y-5">
|
||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||
<section className="min-w-0 space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<Label>待处理图片 {sources.length}/{MAX_BATCH_SOURCES}</Label>
|
||
{!batch ? (
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
disabled={
|
||
sources.length >= MAX_BATCH_SOURCES ||
|
||
Boolean(canvasImageSelectionSession)
|
||
}
|
||
onClick={startCanvasSourceSelection}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Images className="mr-1.5 h-4 w-4" />
|
||
从画布中添加
|
||
</Button>
|
||
<input
|
||
accept="image/*"
|
||
className="hidden"
|
||
multiple
|
||
onChange={(event) => void handleSourceUpload(event)}
|
||
ref={sourceUploadInputRef}
|
||
type="file"
|
||
/>
|
||
<Button
|
||
disabled={isUploadingSources || sources.length >= MAX_BATCH_SOURCES}
|
||
onClick={() => sourceUploadInputRef.current?.click()}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isUploadingSources ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="mr-1.5 h-4 w-4" />
|
||
)}
|
||
上传图片
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||
{Array.from({ length: MAX_BATCH_SOURCES }, (_, index) => {
|
||
const source = sources[index];
|
||
return (
|
||
<div
|
||
className="relative aspect-square min-w-0 overflow-hidden rounded-md border bg-muted/25"
|
||
key={source?.id || `empty-${index}`}
|
||
>
|
||
<span className="absolute left-1.5 top-1.5 z-10 flex h-5 min-w-5 items-center justify-center rounded bg-black/65 px-1 text-[10px] text-white">
|
||
{index + 1}
|
||
</span>
|
||
{source ? (
|
||
<>
|
||
<img
|
||
alt={source.title}
|
||
className="h-full w-full object-cover"
|
||
src={buildImageProxyUrl(source.imageUrl, { width: 240 })}
|
||
/>
|
||
{!batch && index > 0 ? (
|
||
<button
|
||
aria-label={`移除第 ${index + 1} 张图片`}
|
||
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/65 text-white hover:bg-black/80"
|
||
onClick={() => removeSource(source.id)}
|
||
type="button"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
|
||
{source.title}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<button
|
||
aria-label={`添加第 ${index + 1} 张图片`}
|
||
className="flex h-full w-full items-center justify-center text-muted-foreground hover:bg-muted/50"
|
||
disabled={Boolean(batch)}
|
||
onClick={() => sourceUploadInputRef.current?.click()}
|
||
type="button"
|
||
>
|
||
<Plus className="h-5 w-5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`layout-batch-prompt-${primaryNode.id}`}>补充要求</Label>
|
||
<Textarea
|
||
className="min-h-[88px] max-h-[180px]"
|
||
disabled={Boolean(batch)}
|
||
id={`layout-batch-prompt-${primaryNode.id}`}
|
||
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
||
placeholder="可选,只写产品替换细节"
|
||
value={supplementalPrompt}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="min-w-0 space-y-2">
|
||
<Label htmlFor={`layout-batch-reference-${primaryNode.id}`}>目标模版图</Label>
|
||
<Input
|
||
disabled={Boolean(batch)}
|
||
id={`layout-batch-reference-${primaryNode.id}`}
|
||
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
||
placeholder="https://..."
|
||
value={referenceImageUrl}
|
||
/>
|
||
<input
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => void handleReferenceUpload(event)}
|
||
ref={referenceUploadInputRef}
|
||
type="file"
|
||
/>
|
||
<button
|
||
className="flex h-[clamp(180px,34dvh,360px)] w-full items-center justify-center overflow-hidden rounded-md border border-dashed bg-muted/20"
|
||
disabled={Boolean(batch) || isUploadingReference}
|
||
onClick={() => referenceUploadInputRef.current?.click()}
|
||
type="button"
|
||
>
|
||
{isUploadingReference ? (
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
) : isHttpImageUrl(referenceImageUrl) ? (
|
||
<img
|
||
alt="目标模版图"
|
||
className="h-full w-full object-contain"
|
||
src={buildImageProxyUrl(referenceImageUrl, { width: 720 })}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||
<ImagePlus className="h-6 w-6" />
|
||
<span className="text-xs">上传目标模版图</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
</section>
|
||
</div>
|
||
|
||
{batch ? (
|
||
<section className="space-y-3 border-t pt-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<Label>生成结果</Label>
|
||
<div className="text-xs text-muted-foreground">
|
||
{getBatchStatusLabel(batch.status)} · {terminalItemCount}/{batch.totalCount}
|
||
</div>
|
||
</div>
|
||
{!batchIsActive ? (
|
||
<Button onClick={startNewBatchDraft} size="sm" type="button" variant="outline">
|
||
<Plus className="mr-1.5 h-4 w-4" />
|
||
新建批次
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<Progress className="h-1.5" value={batchProgress} />
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{batch.items.map((item) => {
|
||
const canAdd = item.status === "succeeded" && Boolean(item.resultImageUrl) && !item.addedToCanvas;
|
||
const checked = selectedResultIds.has(item.id);
|
||
return (
|
||
<div className="overflow-hidden rounded-md border" key={item.id}>
|
||
<div className="grid grid-cols-2 gap-px bg-border">
|
||
<div className="aspect-square bg-muted">
|
||
<img
|
||
alt={item.sourceTitle || `图片 ${item.position}`}
|
||
className="h-full w-full object-cover"
|
||
src={buildImageProxyUrl(item.sourceImageUrl, { width: 280 })}
|
||
/>
|
||
</div>
|
||
<div className="relative flex aspect-square items-center justify-center bg-muted/40">
|
||
{item.resultImageUrl ? (
|
||
<img
|
||
alt={`结果 ${item.position}`}
|
||
className="h-full w-full object-cover"
|
||
src={buildImageProxyUrl(item.resultThumbnailUrl || item.resultImageUrl, { width: 280 })}
|
||
/>
|
||
) : item.status === "failed" ? (
|
||
<CircleAlert className="h-6 w-6 text-destructive" />
|
||
) : item.status === "running" ? (
|
||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||
) : (
|
||
<Loader2 className="h-5 w-5 text-muted-foreground" />
|
||
)}
|
||
{canAdd ? (
|
||
<label className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded bg-background/90 shadow-sm">
|
||
<input
|
||
checked={checked}
|
||
className="h-4 w-4"
|
||
onChange={(event) => {
|
||
setSelectedResultIds((current) => {
|
||
const next = new Set(current);
|
||
if (event.currentTarget.checked) next.add(item.id);
|
||
else next.delete(item.id);
|
||
return next;
|
||
});
|
||
}}
|
||
type="checkbox"
|
||
/>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex min-h-12 items-center justify-between gap-2 px-3 py-2">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-xs font-medium">
|
||
{item.sourceTitle || `图片 ${item.position}`}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"text-[11px] text-muted-foreground",
|
||
item.status === "failed" && "text-destructive"
|
||
)}
|
||
>
|
||
{getItemStatusLabel(item)}
|
||
</div>
|
||
</div>
|
||
{canAdd ? (
|
||
<Button
|
||
disabled={isAddingResults}
|
||
onClick={() => void addItemsToCanvas([item])}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
加入画布
|
||
</Button>
|
||
) : item.status === "failed" && !batchIsActive ? (
|
||
<Button
|
||
disabled={Boolean(retryingItemId)}
|
||
onClick={() => void retryItem(item)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{retryingItemId === item.id ? (
|
||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||
)}
|
||
重试
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{item.error ? (
|
||
<div className="border-t px-3 py-2 text-xs text-destructive">
|
||
{item.error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter className="shrink-0 gap-2 border-t bg-background px-4 py-3 sm:px-6">
|
||
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
|
||
关闭
|
||
</Button>
|
||
{batch ? (
|
||
<Button
|
||
disabled={selectedSuccessfulItems.length === 0 || isAddingResults}
|
||
onClick={() => void addItemsToCanvas(selectedSuccessfulItems)}
|
||
type="button"
|
||
>
|
||
{isAddingResults ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Images className="mr-2 h-4 w-4" />
|
||
)}
|
||
添加所选到画布 ({selectedSuccessfulItems.length})
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
disabled={
|
||
sources.length === 0 ||
|
||
!isHttpImageUrl(referenceImageUrl) ||
|
||
isSubmitting ||
|
||
isUploadingReference ||
|
||
isUploadingSources
|
||
}
|
||
onClick={() => void handleSubmit()}
|
||
type="button"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||
)}
|
||
批量处理 {sources.length} 张
|
||
{costLabel ? (
|
||
<span className="ml-1 rounded-full bg-white/18 px-1.5 py-0.5 text-[10px] text-white">
|
||
{sources.length} × {costLabel}
|
||
</span>
|
||
) : null}
|
||
</Button>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
{activeCanvasImageSelectionSession && typeof document !== "undefined"
|
||
? createPortal(
|
||
<div
|
||
className="pointer-events-none fixed inset-x-0 bottom-4 z-[10020] flex justify-center px-3 sm:bottom-6"
|
||
data-canvas-layout-replace-picker="true"
|
||
>
|
||
<div
|
||
className="pointer-events-auto flex w-full max-w-[720px] flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5 shadow-2xl sm:flex-nowrap sm:px-4"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[#168cff] text-white">
|
||
<Images className="h-4 w-4" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium">选择画布图片</div>
|
||
<div className="truncate text-xs text-muted-foreground">
|
||
已选 {activeCanvasImageSelectionSession.selectedNodeIds.length}/
|
||
{activeCanvasImageSelectionSession.maxCount}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={cancelCanvasSourceSelection}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
disabled={activeCanvasImageSelectionSession.selectedNodeIds.length === 0}
|
||
onClick={confirmCanvasSourceSelection}
|
||
size="sm"
|
||
type="button"
|
||
>
|
||
确认添加到图片卡槽
|
||
</Button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)
|
||
: null}
|
||
</>
|
||
);
|
||
}
|