ai-chatbot/components/canvas/CanvasTemplateReplaceDialog.tsx

931 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}