diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 1989c7eb5..5f516b036 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -38,6 +38,7 @@ import { ImagePlus, Key, Layers, + LayoutTemplate, Layers3, Loader2, type LucideIcon, @@ -1061,7 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record = imageRemoveBackground: Scissors, imageBoxCutout: Scan, imageLocalEdit: Wand2, - imageTemplateReplace: ImageIcon, + imageLayoutReplace: LayoutTemplate, imageRemoveWatermark: Eraser, imageResize: Maximize2, imageTextEdit: Type, diff --git a/components/canvas/CanvasTemplateReplaceDialog.tsx b/components/canvas/CanvasTemplateReplaceDialog.tsx deleted file mode 100644 index 4129f620e..000000000 --- a/components/canvas/CanvasTemplateReplaceDialog.tsx +++ /dev/null @@ -1,930 +0,0 @@ -"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; - 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; - 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; - 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 - | 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>( - () => createCanvasTemplateReplaceSlots(initialSources) - ); - const [referenceImageUrl, setReferenceImageUrl] = useState(""); - const [supplementalPrompt, setSupplementalPrompt] = useState(""); - const [results, setResults] = useState([]); - const [canvasPickerSlot, setCanvasPickerSlot] = useState(null); - const [canvasSearch, setCanvasSearch] = useState(""); - const [uploadingSlots, setUploadingSlots] = useState([]); - 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([]); - - 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) => { - 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 ( - - - -
-
- - - 模版替换 - - - 最多 5 张替换素材,共用 1 张目标模版图。 - -
- {results.length > 0 && !isProcessing ? ( - - ) : null} -
-
- -
-
-
- - - {selectedSources.length}/{MAX_CANVAS_TEMPLATE_REPLACE_SOURCES} - -
-
- {slots.map((source, slotIndex) => { - const isUploading = uploadingSlots.includes(slotIndex); - const uploadId = `${uploadIdPrefix}-source-${slotIndex}`; - return ( -
- {source ? ( - <> - -
- {slotIndex + 1}. {source.title} -
- {canEditSources ? ( - - ) : null} - - ) : ( -
- {isUploading ? ( - - ) : ( - - )} - 素材 {slotIndex + 1} - {canEditSources ? ( -
- - -
- ) : null} -
- )} - { - const file = event.currentTarget.files?.[0]; - event.currentTarget.value = ""; - void handleSourceUpload(slotIndex, file); - }} - type="file" - /> -
- ); - })} -
- - {canvasPickerSlot !== null && canEditSources ? ( -
-
- setCanvasSearch(event.currentTarget.value)} - placeholder="搜索画布图片" - value={canvasSearch} - /> - -
-
- {filteredCanvasSources.map((source) => { - const alreadySelected = selectedUrls.has(source.imageUrl); - return ( - - ); - })} -
-
- ) : null} -
- -
-
- -
- setReferenceImageUrl(event.currentTarget.value)} - placeholder="填写图片链接或上传" - value={referenceImageUrl} - /> - - { - const file = event.currentTarget.files?.[0]; - event.currentTarget.value = ""; - void handleReferenceUpload(file); - }} - type="file" - /> -
-
- {isUploadingReference ? ( - - ) : isHttpImageUrl(referenceImageUrl) ? ( - - ) : ( - - )} -
- -