931 lines
36 KiB
TypeScript
931 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Check,
|
||
CircleAlert,
|
||
ImagePlus,
|
||
Images,
|
||
LayoutTemplate,
|
||
Loader2,
|
||
Plus,
|
||
RefreshCw,
|
||
Trash2,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
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 { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
createCanvasTemplateReplaceSlots,
|
||
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
|
||
normalizeCanvasTemplateReplaceSources,
|
||
replaceCanvasTemplateSourceSlot,
|
||
type CanvasTemplateReplaceSource,
|
||
} from "@/lib/canvas/template-replace-batch";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import { cn } from "@/lib/utils";
|
||
import { imageGenerationService } from "@/services/canvas";
|
||
|
||
const TEMPLATE_REPLACE_POLL_INTERVAL_MS = 2500;
|
||
|
||
type TemplateReplaceStatus =
|
||
| "waiting"
|
||
| "submitting"
|
||
| "queued"
|
||
| "processing"
|
||
| "completed"
|
||
| "failed";
|
||
|
||
type TemplateReplaceQueueStatus = {
|
||
queuePosition?: number;
|
||
sameModelRunningCount?: number;
|
||
concurrencyLimit?: number;
|
||
estimatedWaitMs?: number;
|
||
};
|
||
|
||
export type CanvasTemplateReplaceResult = {
|
||
id: string;
|
||
batchId: string;
|
||
source: CanvasTemplateReplaceSource;
|
||
referenceImageUrl: string;
|
||
resultImageUrl?: string;
|
||
taskId?: string;
|
||
status: TemplateReplaceStatus;
|
||
queueStatus?: TemplateReplaceQueueStatus;
|
||
error?: string;
|
||
addedToCanvas?: boolean;
|
||
};
|
||
|
||
type PersistedTemplateReplaceBatch = {
|
||
version: 1;
|
||
slots: Array<CanvasTemplateReplaceSource | null>;
|
||
referenceImageUrl: string;
|
||
supplementalPrompt: string;
|
||
results: CanvasTemplateReplaceResult[];
|
||
};
|
||
|
||
type CanvasTemplateReplaceDialogProps = {
|
||
availableSources: CanvasTemplateReplaceSource[];
|
||
canvasId: string;
|
||
initialSources: CanvasTemplateReplaceSource[];
|
||
open: boolean;
|
||
projectId?: string | null;
|
||
triggerNodeId: string;
|
||
onAddToCanvas: (
|
||
result: CanvasTemplateReplaceResult,
|
||
resultIndex: number
|
||
) => void | Promise<void>;
|
||
onOpenChange: (open: boolean) => void;
|
||
};
|
||
|
||
function isHttpImageUrl(value: unknown): value is string {
|
||
return /^https?:\/\//i.test(String(value || "").trim());
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
}
|
||
|
||
function createLocalId(prefix: string) {
|
||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||
return `${prefix}-${crypto.randomUUID()}`;
|
||
}
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
function normalizeQueueStatus(value: unknown): TemplateReplaceQueueStatus | undefined {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||
const queue = value as Record<string, unknown>;
|
||
const normalizeNumber = (entry: unknown) => {
|
||
const number = Number(entry);
|
||
return Number.isFinite(number) ? Math.max(0, number) : undefined;
|
||
};
|
||
return {
|
||
queuePosition: normalizeNumber(queue.queuePosition),
|
||
sameModelRunningCount: normalizeNumber(queue.sameModelRunningCount),
|
||
concurrencyLimit: normalizeNumber(queue.concurrencyLimit),
|
||
estimatedWaitMs: normalizeNumber(queue.estimatedWaitMs),
|
||
};
|
||
}
|
||
|
||
function getStatusLabel(result: CanvasTemplateReplaceResult) {
|
||
if (result.status === "waiting") return "等待上一张";
|
||
if (result.status === "submitting") return "正在提交";
|
||
if (result.status === "queued") {
|
||
const position = Math.max(0, Number(result.queueStatus?.queuePosition || 0));
|
||
return position > 0 ? `排队中(前方 ${position} 个)` : "排队中";
|
||
}
|
||
if (result.status === "processing") return "处理中";
|
||
if (result.status === "completed") return "已完成";
|
||
return "失败";
|
||
}
|
||
|
||
function getPersistedBatch(
|
||
storageKey: string
|
||
): PersistedTemplateReplaceBatch | null {
|
||
if (typeof window === "undefined") return null;
|
||
try {
|
||
const raw = JSON.parse(window.localStorage.getItem(storageKey) || "null") as
|
||
| Partial<PersistedTemplateReplaceBatch>
|
||
| null;
|
||
if (!raw || raw.version !== 1) return null;
|
||
|
||
const slots = createCanvasTemplateReplaceSlots(
|
||
Array.isArray(raw.slots) ? raw.slots : []
|
||
);
|
||
const referenceImageUrl = isHttpImageUrl(raw.referenceImageUrl)
|
||
? raw.referenceImageUrl.trim()
|
||
: "";
|
||
const results = (Array.isArray(raw.results) ? raw.results : [])
|
||
.map((result): CanvasTemplateReplaceResult | null => {
|
||
if (!result || typeof result !== "object") return null;
|
||
const source = normalizeCanvasTemplateReplaceSources([result.source])[0];
|
||
if (!source || !isHttpImageUrl(result.referenceImageUrl)) return null;
|
||
const taskId = String(result.taskId || "").trim() || undefined;
|
||
const resultImageUrl = isHttpImageUrl(result.resultImageUrl)
|
||
? result.resultImageUrl.trim()
|
||
: undefined;
|
||
let status: TemplateReplaceStatus = "waiting";
|
||
if (resultImageUrl) status = "completed";
|
||
else if (result.status === "failed") status = "failed";
|
||
else if (taskId) status = "queued";
|
||
|
||
return {
|
||
id: String(result.id || createLocalId("template-result")),
|
||
batchId: String(result.batchId || createLocalId("template-batch")),
|
||
source,
|
||
referenceImageUrl: result.referenceImageUrl.trim(),
|
||
resultImageUrl,
|
||
taskId,
|
||
status,
|
||
queueStatus: normalizeQueueStatus(result.queueStatus),
|
||
error: String(result.error || "").trim() || undefined,
|
||
addedToCanvas: result.addedToCanvas === true,
|
||
};
|
||
})
|
||
.filter((result): result is CanvasTemplateReplaceResult => Boolean(result));
|
||
|
||
return {
|
||
version: 1,
|
||
slots,
|
||
referenceImageUrl,
|
||
supplementalPrompt: String(raw.supplementalPrompt || "").slice(0, 2000),
|
||
results,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function CanvasTemplateReplaceDialog({
|
||
availableSources,
|
||
canvasId,
|
||
initialSources,
|
||
open,
|
||
projectId,
|
||
triggerNodeId,
|
||
onAddToCanvas,
|
||
onOpenChange,
|
||
}: CanvasTemplateReplaceDialogProps) {
|
||
const uploadIdPrefix = useId();
|
||
const storageKey = `canvas-template-replace:${canvasId}:${triggerNodeId}`;
|
||
const [slots, setSlots] = useState<Array<CanvasTemplateReplaceSource | null>>(
|
||
() => createCanvasTemplateReplaceSlots(initialSources)
|
||
);
|
||
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
||
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
||
const [results, setResults] = useState<CanvasTemplateReplaceResult[]>([]);
|
||
const [canvasPickerSlot, setCanvasPickerSlot] = useState<number | null>(null);
|
||
const [canvasSearch, setCanvasSearch] = useState("");
|
||
const [uploadingSlots, setUploadingSlots] = useState<number[]>([]);
|
||
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const hydratedRef = useRef(false);
|
||
const skipNextPersistRef = useRef(false);
|
||
const runningRef = useRef(false);
|
||
const mountedRef = useRef(true);
|
||
const resultsRef = useRef<CanvasTemplateReplaceResult[]>([]);
|
||
|
||
const setResultsWithRef = useCallback(
|
||
(
|
||
updater:
|
||
| CanvasTemplateReplaceResult[]
|
||
| ((current: CanvasTemplateReplaceResult[]) => CanvasTemplateReplaceResult[])
|
||
) => {
|
||
setResults((current) => {
|
||
const next = typeof updater === "function" ? updater(current) : updater;
|
||
resultsRef.current = next;
|
||
return next;
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
useEffect(() => {
|
||
mountedRef.current = true;
|
||
return () => {
|
||
mountedRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!open || hydratedRef.current) return;
|
||
hydratedRef.current = true;
|
||
const persisted = getPersistedBatch(storageKey);
|
||
if (persisted) {
|
||
skipNextPersistRef.current = true;
|
||
setSlots(persisted.slots);
|
||
setReferenceImageUrl(persisted.referenceImageUrl);
|
||
setSupplementalPrompt(persisted.supplementalPrompt);
|
||
resultsRef.current = persisted.results;
|
||
setResults(persisted.results);
|
||
return;
|
||
}
|
||
setSlots(createCanvasTemplateReplaceSlots(initialSources));
|
||
}, [initialSources, open, storageKey]);
|
||
|
||
useEffect(() => {
|
||
if (!hydratedRef.current || typeof window === "undefined") return;
|
||
if (skipNextPersistRef.current) {
|
||
skipNextPersistRef.current = false;
|
||
return;
|
||
}
|
||
const payload: PersistedTemplateReplaceBatch = {
|
||
version: 1,
|
||
slots,
|
||
referenceImageUrl,
|
||
supplementalPrompt,
|
||
results,
|
||
};
|
||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||
}, [referenceImageUrl, results, slots, storageKey, supplementalPrompt]);
|
||
|
||
const selectedSources = useMemo(
|
||
() => normalizeCanvasTemplateReplaceSources(slots),
|
||
[slots]
|
||
);
|
||
const selectedUrls = useMemo(
|
||
() => new Set(selectedSources.map((source) => source.imageUrl)),
|
||
[selectedSources]
|
||
);
|
||
const filteredCanvasSources = useMemo(() => {
|
||
const query = canvasSearch.trim().toLowerCase();
|
||
return availableSources.filter(
|
||
(source) =>
|
||
!query ||
|
||
source.title.toLowerCase().includes(query) ||
|
||
source.imageUrl.toLowerCase().includes(query)
|
||
);
|
||
}, [availableSources, canvasSearch]);
|
||
const completedCount = results.filter((result) => result.status === "completed").length;
|
||
const canEditSources = !isProcessing && results.length === 0;
|
||
const canStart =
|
||
selectedSources.length > 0 &&
|
||
isHttpImageUrl(referenceImageUrl) &&
|
||
!isProcessing &&
|
||
results.length === 0 &&
|
||
uploadingSlots.length === 0 &&
|
||
!isUploadingReference;
|
||
|
||
const updateResult = useCallback(
|
||
(id: string, patch: Partial<CanvasTemplateReplaceResult>) => {
|
||
setResultsWithRef((current) =>
|
||
current.map((result) => (result.id === id ? { ...result, ...patch } : result))
|
||
);
|
||
},
|
||
[setResultsWithRef]
|
||
);
|
||
|
||
const uploadImage = useCallback(async (file: File, fallbackMessage: string) => {
|
||
if (!file.type.startsWith("image/")) {
|
||
throw new Error("请上传图片文件");
|
||
}
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const response = await fetchUploadWithRetry(formData);
|
||
const payload = await response.json().catch(() => null);
|
||
const uploadedUrl = String(payload?.url || "").trim();
|
||
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
|
||
throw new Error(payload?.error || fallbackMessage);
|
||
}
|
||
return uploadedUrl;
|
||
}, []);
|
||
|
||
const handleSourceUpload = useCallback(
|
||
async (slotIndex: number, file?: File) => {
|
||
if (!file || !canEditSources) return;
|
||
setUploadingSlots((current) => [...new Set([...current, slotIndex])]);
|
||
try {
|
||
const imageUrl = await uploadImage(file, "替换素材上传失败");
|
||
setSlots((current) =>
|
||
replaceCanvasTemplateSourceSlot(current, slotIndex, {
|
||
id: createLocalId("template-upload"),
|
||
imageUrl,
|
||
title: file.name || `上传素材${slotIndex + 1}`,
|
||
origin: "upload",
|
||
})
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "替换素材上传失败");
|
||
} finally {
|
||
setUploadingSlots((current) => current.filter((index) => index !== slotIndex));
|
||
}
|
||
},
|
||
[canEditSources, uploadImage]
|
||
);
|
||
|
||
const handleReferenceUpload = useCallback(
|
||
async (file?: File) => {
|
||
if (!file || !canEditSources) return;
|
||
setIsUploadingReference(true);
|
||
try {
|
||
setReferenceImageUrl(await uploadImage(file, "目标模版图上传失败"));
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "目标模版图上传失败");
|
||
} finally {
|
||
setIsUploadingReference(false);
|
||
}
|
||
},
|
||
[canEditSources, uploadImage]
|
||
);
|
||
|
||
const pollTask = useCallback(
|
||
async (resultId: string, taskId: string) => {
|
||
while (mountedRef.current) {
|
||
const taskResponse = await imageGenerationService.getImageTask(taskId);
|
||
const task = taskResponse.data?.task;
|
||
if (!taskResponse.success || !task) {
|
||
await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS);
|
||
continue;
|
||
}
|
||
|
||
const status = String(task.status || "").toLowerCase();
|
||
const queueStatus = normalizeQueueStatus(task.queueStatus);
|
||
if (status === "pending") {
|
||
updateResult(resultId, { status: "queued", queueStatus, error: undefined });
|
||
} else if (status === "running" || status === "processing") {
|
||
updateResult(resultId, { status: "processing", queueStatus, error: undefined });
|
||
} else if (status === "succeeded" || status === "completed") {
|
||
const image = task.result?.images?.[0];
|
||
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
|
||
if (!isHttpImageUrl(imageUrl)) {
|
||
updateResult(resultId, { taskId: undefined });
|
||
throw new Error("任务已完成,但没有返回结果图片");
|
||
}
|
||
updateResult(resultId, {
|
||
status: "completed",
|
||
resultImageUrl: imageUrl,
|
||
queueStatus: undefined,
|
||
error: undefined,
|
||
});
|
||
return;
|
||
} else if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||
updateResult(resultId, { taskId: undefined });
|
||
throw new Error(task.error || "模版替换生成失败");
|
||
}
|
||
|
||
await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS);
|
||
}
|
||
},
|
||
[updateResult]
|
||
);
|
||
|
||
const runSequential = useCallback(
|
||
async (resultIds?: string[]) => {
|
||
if (runningRef.current) return;
|
||
runningRef.current = true;
|
||
setIsProcessing(true);
|
||
const requestedIds = resultIds ? new Set(resultIds) : null;
|
||
|
||
try {
|
||
const orderedIds = resultsRef.current
|
||
.filter((result) => !requestedIds || requestedIds.has(result.id))
|
||
.map((result) => result.id);
|
||
|
||
for (const resultId of orderedIds) {
|
||
if (!mountedRef.current) return;
|
||
const current = resultsRef.current.find((result) => result.id === resultId);
|
||
if (!current || current.status === "completed") continue;
|
||
|
||
try {
|
||
let taskId = current.taskId;
|
||
if (!taskId) {
|
||
updateResult(resultId, {
|
||
status: "submitting",
|
||
error: undefined,
|
||
queueStatus: undefined,
|
||
});
|
||
const submitResult = await imageGenerationService.layoutReplace({
|
||
sourceImageUrl: current.source.imageUrl,
|
||
referenceImageUrl: current.referenceImageUrl,
|
||
supplementalPrompt: supplementalPrompt.trim() || undefined,
|
||
sourceNodeId: current.source.sourceNodeId,
|
||
agentProjectId: projectId || undefined,
|
||
referenceLabel: "无限画布批量模版替换目标图",
|
||
imageSize: "2K",
|
||
debugMeta: {
|
||
operation: "canvas-template-replace-batch",
|
||
batchId: current.batchId,
|
||
canvasId,
|
||
sourceIndex: resultsRef.current.findIndex(
|
||
(result) => result.id === resultId
|
||
),
|
||
},
|
||
});
|
||
taskId = String(submitResult.data?.taskId || "").trim();
|
||
if (!submitResult.success || !taskId) {
|
||
throw new Error(submitResult.error || "模版替换提交失败");
|
||
}
|
||
updateResult(resultId, { taskId, status: "queued" });
|
||
}
|
||
await pollTask(resultId, taskId);
|
||
} catch (error) {
|
||
updateResult(resultId, {
|
||
status: "failed",
|
||
queueStatus: undefined,
|
||
error: error instanceof Error ? error.message : "模版替换生成失败",
|
||
});
|
||
}
|
||
}
|
||
} finally {
|
||
runningRef.current = false;
|
||
if (mountedRef.current) setIsProcessing(false);
|
||
}
|
||
},
|
||
[canvasId, pollTask, projectId, supplementalPrompt, updateResult]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!open ||
|
||
!hydratedRef.current ||
|
||
runningRef.current ||
|
||
!results.some((result) =>
|
||
["waiting", "submitting", "queued", "processing"].includes(result.status)
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
void runSequential();
|
||
}, [open, results, runSequential]);
|
||
|
||
const handleStart = useCallback(() => {
|
||
if (!canStart) return;
|
||
const batchId = createLocalId("template-batch");
|
||
const nextResults = selectedSources.map(
|
||
(source, index): CanvasTemplateReplaceResult => ({
|
||
id: createLocalId("template-result"),
|
||
batchId,
|
||
source,
|
||
referenceImageUrl: referenceImageUrl.trim(),
|
||
status: index === 0 ? "submitting" : "waiting",
|
||
})
|
||
);
|
||
resultsRef.current = nextResults;
|
||
setResults(nextResults);
|
||
toast.success(`已开始处理 ${nextResults.length} 张图片`);
|
||
void runSequential(nextResults.map((result) => result.id));
|
||
}, [canStart, referenceImageUrl, runSequential, selectedSources]);
|
||
|
||
const handleRetry = useCallback(
|
||
(result: CanvasTemplateReplaceResult) => {
|
||
if (isProcessing) return;
|
||
updateResult(result.id, {
|
||
status: result.taskId ? "queued" : "waiting",
|
||
error: undefined,
|
||
queueStatus: undefined,
|
||
});
|
||
window.setTimeout(() => void runSequential([result.id]), 0);
|
||
},
|
||
[isProcessing, runSequential, updateResult]
|
||
);
|
||
|
||
const handleAddResult = useCallback(
|
||
async (result: CanvasTemplateReplaceResult, resultIndex: number) => {
|
||
if (!result.resultImageUrl || result.addedToCanvas) return;
|
||
try {
|
||
await onAddToCanvas(result, resultIndex);
|
||
updateResult(result.id, { addedToCanvas: true });
|
||
toast.success("结果已添加到画布");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "添加到画布失败");
|
||
}
|
||
},
|
||
[onAddToCanvas, updateResult]
|
||
);
|
||
|
||
const handleAddAll = useCallback(async () => {
|
||
const completedResults = resultsRef.current.filter(
|
||
(result) => result.status === "completed" && !result.addedToCanvas
|
||
);
|
||
for (const result of completedResults) {
|
||
const resultIndex = resultsRef.current.findIndex((item) => item.id === result.id);
|
||
await handleAddResult(result, resultIndex);
|
||
}
|
||
}, [handleAddResult]);
|
||
|
||
const handleReset = useCallback(() => {
|
||
if (isProcessing) return;
|
||
const nextSlots = createCanvasTemplateReplaceSlots(initialSources);
|
||
resultsRef.current = [];
|
||
setResults([]);
|
||
setSlots(nextSlots);
|
||
setReferenceImageUrl("");
|
||
setSupplementalPrompt("");
|
||
setCanvasPickerSlot(null);
|
||
if (typeof window !== "undefined") window.localStorage.removeItem(storageKey);
|
||
}, [initialSources, isProcessing, storageKey]);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1080px] 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 items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<DialogTitle className="flex items-center gap-2 text-base">
|
||
<LayoutTemplate className="h-4 w-4" />
|
||
模版替换
|
||
</DialogTitle>
|
||
<DialogDescription className="mt-1">
|
||
最多 5 张替换素材,共用 1 张目标模版图。
|
||
</DialogDescription>
|
||
</div>
|
||
{results.length > 0 && !isProcessing ? (
|
||
<Button className="shrink-0" onClick={handleReset} size="sm" variant="outline">
|
||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||
新建一批
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||
<section className="space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Label>替换素材</Label>
|
||
<span className="text-muted-foreground text-xs">
|
||
{selectedSources.length}/{MAX_CANVAS_TEMPLATE_REPLACE_SOURCES}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||
{slots.map((source, slotIndex) => {
|
||
const isUploading = uploadingSlots.includes(slotIndex);
|
||
const uploadId = `${uploadIdPrefix}-source-${slotIndex}`;
|
||
return (
|
||
<div
|
||
className="group relative aspect-square min-w-0 overflow-hidden rounded-lg border bg-muted/30"
|
||
key={`source-slot-${slotIndex}`}
|
||
>
|
||
{source ? (
|
||
<>
|
||
<AgentPreviewImage
|
||
alt={source.title}
|
||
className="h-full w-full object-cover"
|
||
previewQuality={78}
|
||
previewWidth={360}
|
||
src={source.imageUrl}
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
|
||
{slotIndex + 1}. {source.title}
|
||
</div>
|
||
{canEditSources ? (
|
||
<button
|
||
aria-label={`移除素材 ${slotIndex + 1}`}
|
||
className="absolute top-1.5 right-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/70 text-white opacity-100 hover:bg-black sm:opacity-0 sm:group-hover:opacity-100"
|
||
onClick={() =>
|
||
setSlots((current) =>
|
||
replaceCanvasTemplateSourceSlot(current, slotIndex, null)
|
||
)
|
||
}
|
||
type="button"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<div className="flex h-full flex-col items-center justify-center gap-2 p-2 text-muted-foreground">
|
||
{isUploading ? (
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
) : (
|
||
<ImagePlus className="h-5 w-5" />
|
||
)}
|
||
<span className="text-xs">素材 {slotIndex + 1}</span>
|
||
{canEditSources ? (
|
||
<div className="flex gap-1">
|
||
<Button
|
||
className="h-7 px-2 text-xs"
|
||
onClick={() => setCanvasPickerSlot(slotIndex)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Images className="mr-1 h-3 w-3" />
|
||
画布
|
||
</Button>
|
||
<Button
|
||
asChild
|
||
className="h-7 px-2 text-xs"
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<label htmlFor={uploadId}>
|
||
<Upload className="h-3 w-3" />
|
||
</label>
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
<input
|
||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||
className="sr-only"
|
||
disabled={!canEditSources || isUploading}
|
||
id={uploadId}
|
||
onChange={(event) => {
|
||
const file = event.currentTarget.files?.[0];
|
||
event.currentTarget.value = "";
|
||
void handleSourceUpload(slotIndex, file);
|
||
}}
|
||
type="file"
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{canvasPickerSlot !== null && canEditSources ? (
|
||
<div className="rounded-lg border bg-background p-3">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<Input
|
||
className="h-9"
|
||
onChange={(event) => setCanvasSearch(event.currentTarget.value)}
|
||
placeholder="搜索画布图片"
|
||
value={canvasSearch}
|
||
/>
|
||
<Button
|
||
className="shrink-0"
|
||
onClick={() => setCanvasPickerSlot(null)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
取消
|
||
</Button>
|
||
</div>
|
||
<div className="grid max-h-52 grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-5 md:grid-cols-7">
|
||
{filteredCanvasSources.map((source) => {
|
||
const alreadySelected = selectedUrls.has(source.imageUrl);
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"relative aspect-square overflow-hidden rounded-lg border bg-muted/30 text-left",
|
||
alreadySelected
|
||
? "cursor-not-allowed opacity-40"
|
||
: "hover:border-primary"
|
||
)}
|
||
disabled={alreadySelected}
|
||
key={source.id}
|
||
onClick={() => {
|
||
setSlots((current) =>
|
||
replaceCanvasTemplateSourceSlot(
|
||
current,
|
||
canvasPickerSlot,
|
||
source
|
||
)
|
||
);
|
||
setCanvasPickerSlot(null);
|
||
setCanvasSearch("");
|
||
}}
|
||
title={source.title}
|
||
type="button"
|
||
>
|
||
<AgentPreviewImage
|
||
alt={source.title}
|
||
className="h-full w-full object-cover"
|
||
previewQuality={72}
|
||
previewWidth={240}
|
||
src={source.imageUrl}
|
||
/>
|
||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-1.5 py-1 text-[10px] text-white">
|
||
{source.title}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`${uploadIdPrefix}-reference`}>目标模版图</Label>
|
||
<div className="flex min-w-0 gap-2">
|
||
<Input
|
||
className="min-w-0"
|
||
disabled={!canEditSources}
|
||
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
||
placeholder="填写图片链接或上传"
|
||
value={referenceImageUrl}
|
||
/>
|
||
<Button
|
||
asChild
|
||
className="shrink-0"
|
||
variant="outline"
|
||
>
|
||
<label htmlFor={`${uploadIdPrefix}-reference`}>
|
||
{isUploadingReference ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="mr-1.5 h-4 w-4" />
|
||
)}
|
||
上传
|
||
</label>
|
||
</Button>
|
||
<input
|
||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||
className="sr-only"
|
||
disabled={!canEditSources || isUploadingReference}
|
||
id={`${uploadIdPrefix}-reference`}
|
||
onChange={(event) => {
|
||
const file = event.currentTarget.files?.[0];
|
||
event.currentTarget.value = "";
|
||
void handleReferenceUpload(file);
|
||
}}
|
||
type="file"
|
||
/>
|
||
</div>
|
||
<div className="flex h-[clamp(160px,28dvh,280px)] items-center justify-center overflow-hidden rounded-lg border border-dashed bg-muted/20">
|
||
{isUploadingReference ? (
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
) : isHttpImageUrl(referenceImageUrl) ? (
|
||
<AgentPreviewImage
|
||
alt="目标模版图"
|
||
className="max-h-full max-w-full object-contain"
|
||
previewQuality={82}
|
||
previewWidth={720}
|
||
src={referenceImageUrl}
|
||
/>
|
||
) : (
|
||
<LayoutTemplate className="h-8 w-8 text-muted-foreground/45" />
|
||
)}
|
||
</div>
|
||
<Label htmlFor={`${uploadIdPrefix}-prompt`}>补充要求</Label>
|
||
<Textarea
|
||
className="min-h-20 max-h-36"
|
||
disabled={!canEditSources}
|
||
id={`${uploadIdPrefix}-prompt`}
|
||
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
||
placeholder="可选:补充材质、颜色或替换细节"
|
||
value={supplementalPrompt}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Label>生图结果</Label>
|
||
{results.length > 0 ? (
|
||
<span className="text-muted-foreground text-xs">
|
||
{completedCount}/{results.length}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{results.length === 0 ? (
|
||
<div className="flex h-[clamp(220px,42dvh,420px)] items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground">
|
||
<div className="text-center">
|
||
<Images className="mx-auto mb-2 h-7 w-7 opacity-45" />
|
||
<p className="text-sm">待生成</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid max-h-[clamp(300px,52dvh,560px)] grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||
{results.map((result, resultIndex) => {
|
||
const active = ["submitting", "queued", "processing"].includes(
|
||
result.status
|
||
);
|
||
return (
|
||
<div
|
||
className="min-w-0 overflow-hidden rounded-lg border bg-background"
|
||
key={result.id}
|
||
>
|
||
<div className="relative aspect-[4/3] bg-muted/30">
|
||
<AgentPreviewImage
|
||
alt={`${result.source.title}结果`}
|
||
className="h-full w-full object-contain"
|
||
previewQuality={82}
|
||
previewWidth={640}
|
||
src={result.resultImageUrl || result.source.imageUrl}
|
||
/>
|
||
{result.status !== "completed" ? (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-[1px]">
|
||
<div className="max-w-[85%] text-center">
|
||
{active ? (
|
||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
||
) : result.status === "failed" ? (
|
||
<CircleAlert className="mx-auto mb-2 h-5 w-5 text-destructive" />
|
||
) : (
|
||
<Loader2 className="mx-auto mb-2 h-5 w-5 text-muted-foreground" />
|
||
)}
|
||
<p className="text-sm">{getStatusLabel(result)}</p>
|
||
{result.error ? (
|
||
<p className="mt-1 line-clamp-2 text-destructive text-xs">
|
||
{result.error}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2 p-2">
|
||
<span className="min-w-0 truncate text-xs">
|
||
{resultIndex + 1}. {result.source.title}
|
||
</span>
|
||
{result.status === "completed" ? (
|
||
<Button
|
||
className="h-8 shrink-0 px-2 text-xs"
|
||
disabled={result.addedToCanvas}
|
||
onClick={() => void handleAddResult(result, resultIndex)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{result.addedToCanvas ? (
|
||
<Check className="mr-1 h-3.5 w-3.5" />
|
||
) : (
|
||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
)}
|
||
{result.addedToCanvas ? "已添加" : "添加到画布"}
|
||
</Button>
|
||
) : result.status === "failed" ? (
|
||
<Button
|
||
className="h-8 shrink-0 px-2 text-xs"
|
||
disabled={isProcessing}
|
||
onClick={() => handleRetry(result)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||
重试
|
||
</Button>
|
||
) : (
|
||
<span className="shrink-0 text-muted-foreground text-xs">
|
||
{getStatusLabel(result)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</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">
|
||
{isProcessing ? "收起" : "关闭"}
|
||
</Button>
|
||
{completedCount > 0 ? (
|
||
<Button
|
||
disabled={!results.some(
|
||
(result) => result.status === "completed" && !result.addedToCanvas
|
||
)}
|
||
onClick={() => void handleAddAll()}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Plus className="mr-1.5 h-4 w-4" />
|
||
全部添加到画布
|
||
</Button>
|
||
) : null}
|
||
<Button disabled={!canStart} onClick={handleStart} type="button">
|
||
{isProcessing ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<LayoutTemplate className="mr-1.5 h-4 w-4" />
|
||
)}
|
||
{isProcessing
|
||
? `处理中 ${completedCount}/${results.length}`
|
||
: `批量处理${selectedSources.length ? `(${selectedSources.length})` : ""}`}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|