feat(canvas): add batch template replacement
This commit is contained in:
parent
3009727ec1
commit
bf6bd01fcb
@ -1061,7 +1061,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
|||||||
imageRemoveBackground: Scissors,
|
imageRemoveBackground: Scissors,
|
||||||
imageBoxCutout: Scan,
|
imageBoxCutout: Scan,
|
||||||
imageLocalEdit: Wand2,
|
imageLocalEdit: Wand2,
|
||||||
imagePsdTemplateReplace: ImageIcon,
|
imageTemplateReplace: ImageIcon,
|
||||||
imageRemoveWatermark: Eraser,
|
imageRemoveWatermark: Eraser,
|
||||||
imageResize: Maximize2,
|
imageResize: Maximize2,
|
||||||
imageTextEdit: Type,
|
imageTextEdit: Type,
|
||||||
|
|||||||
930
components/canvas/CanvasTemplateReplaceDialog.tsx
Normal file
930
components/canvas/CanvasTemplateReplaceDialog.tsx
Normal file
@ -0,0 +1,930 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -716,14 +716,14 @@ export function CanvasWorkspace({
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLocalEdit"
|
"imageLocalEdit"
|
||||||
);
|
);
|
||||||
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
const selectedImageTemplateReplaceItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imagePsdTemplateReplace"
|
"imageTemplateReplace"
|
||||||
);
|
);
|
||||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||||
const selectedImageLocalEditEnabled = true;
|
const selectedImageLocalEditEnabled = true;
|
||||||
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
|
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
|
||||||
const selectedImagePsdTemplateReplaceEnabled = true;
|
const selectedImageTemplateReplaceEnabled = true;
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
@ -2536,7 +2536,7 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLocalEditItem.label}
|
{selectedImageLocalEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedImagePsdTemplateReplaceEnabled &&
|
{selectedImageTemplateReplaceEnabled &&
|
||||||
selectedImageNodes.length === 1 ? (
|
selectedImageNodes.length === 1 ? (
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
@ -2547,16 +2547,16 @@ export function CanvasWorkspace({
|
|||||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
|
requestImageNodeAction(selectedImageNodes[0].id, "template-replace")
|
||||||
}
|
}
|
||||||
title={selectedImagePsdTemplateReplaceItem.label}
|
title={selectedImageTemplateReplaceItem.label}
|
||||||
>
|
>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-3.5 w-3.5"
|
className="h-3.5 w-3.5"
|
||||||
fallback={FileImage}
|
fallback={FileImage}
|
||||||
item={selectedImagePsdTemplateReplaceItem}
|
item={selectedImageTemplateReplaceItem}
|
||||||
/>
|
/>
|
||||||
{selectedImagePsdTemplateReplaceItem.label}
|
{selectedImageTemplateReplaceItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -52,6 +52,10 @@ import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceO
|
|||||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||||
|
import {
|
||||||
|
CanvasTemplateReplaceDialog,
|
||||||
|
type CanvasTemplateReplaceResult,
|
||||||
|
} from "@/components/canvas/CanvasTemplateReplaceDialog";
|
||||||
import {
|
import {
|
||||||
areImageAdjustmentsEqual,
|
areImageAdjustmentsEqual,
|
||||||
buildImageAdjustmentFilterStyle,
|
buildImageAdjustmentFilterStyle,
|
||||||
@ -132,15 +136,10 @@ import {
|
|||||||
storeCanvasAgentAnnotationEditRequest,
|
storeCanvasAgentAnnotationEditRequest,
|
||||||
type CanvasAgentPendingRequestWindow,
|
type CanvasAgentPendingRequestWindow,
|
||||||
} from "@/lib/canvas/agent-pending-requests";
|
} from "@/lib/canvas/agent-pending-requests";
|
||||||
import {
|
|
||||||
createCanvasPsdTemplateLaunchToken,
|
|
||||||
saveCanvasPsdTemplateLaunchPayload,
|
|
||||||
} from "@/lib/canvas/psd-template-launch";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@ -226,6 +225,7 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
|
import type { CanvasTemplateReplaceSource } from "@/lib/canvas/template-replace-batch";
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||||
@ -1357,9 +1357,9 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
||||||
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
||||||
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
const imageTemplateReplaceItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imagePsdTemplateReplace"
|
"imageTemplateReplace"
|
||||||
);
|
);
|
||||||
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
@ -1433,8 +1433,8 @@ export function ImageNode({
|
|||||||
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
||||||
// 也必须默认展示,避免历史配置把新功能吞掉。
|
// 也必须默认展示,避免历史配置把新功能吞掉。
|
||||||
const imageLocalEditEnabled = true;
|
const imageLocalEditEnabled = true;
|
||||||
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
|
// 模版替换是无限画布图片的固定能力,历史配置缺少新 key 时仍保持可见。
|
||||||
const imagePsdTemplateReplaceEnabled = true;
|
const imageTemplateReplaceEnabled = true;
|
||||||
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLayerSplit"
|
"imageLayerSplit"
|
||||||
@ -1500,7 +1500,7 @@ export function ImageNode({
|
|||||||
{ key: "textEdit", enabled: imageTextEditEnabled },
|
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||||||
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||||||
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||||||
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
|
{ key: "templateReplace", enabled: imageTemplateReplaceEnabled },
|
||||||
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||||||
{ key: "resize", enabled: imageResizeEnabled },
|
{ key: "resize", enabled: imageResizeEnabled },
|
||||||
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||||||
@ -1536,7 +1536,7 @@ export function ImageNode({
|
|||||||
imageRemoveBackgroundEnabled ||
|
imageRemoveBackgroundEnabled ||
|
||||||
imageRemoveWatermarkEnabled ||
|
imageRemoveWatermarkEnabled ||
|
||||||
imageEnhanceEnabled ||
|
imageEnhanceEnabled ||
|
||||||
imagePsdTemplateReplaceEnabled ||
|
imageTemplateReplaceEnabled ||
|
||||||
imageNoteEnabled ||
|
imageNoteEnabled ||
|
||||||
hasCanvasAgentAnnotations ||
|
hasCanvasAgentAnnotations ||
|
||||||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
||||||
@ -1695,37 +1695,7 @@ export function ImageNode({
|
|||||||
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
||||||
);
|
);
|
||||||
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
||||||
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
|
const [templateReplaceDialogOpen, setTemplateReplaceDialogOpen] = useState(false);
|
||||||
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
|
|
||||||
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
|
|
||||||
useSWR<{
|
|
||||||
success?: boolean;
|
|
||||||
templates?: Array<{ brandName?: string | null }>;
|
|
||||||
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
|
|
||||||
const availablePsdTemplateBrands = useMemo(
|
|
||||||
() =>
|
|
||||||
Array.from(
|
|
||||||
new Set(
|
|
||||||
(psdTemplateSummary?.templates || [])
|
|
||||||
.map((template) => String(template?.brandName || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[psdTemplateSummary?.templates]
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
psdTemplateDialogOpen &&
|
|
||||||
!psdTemplateBrandName.trim() &&
|
|
||||||
availablePsdTemplateBrands.length === 1
|
|
||||||
) {
|
|
||||||
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
availablePsdTemplateBrands,
|
|
||||||
psdTemplateBrandName,
|
|
||||||
psdTemplateDialogOpen,
|
|
||||||
]);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
@ -2354,6 +2324,51 @@ export function ImageNode({
|
|||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const displayTitle = getImageNodeTitle(data, node.id);
|
const displayTitle = getImageNodeTitle(data, node.id);
|
||||||
|
const availableTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(
|
||||||
|
() =>
|
||||||
|
allNodes.flatMap((canvasNode) => {
|
||||||
|
if (canvasNode.type !== "image") return [];
|
||||||
|
const imageData = canvasNode.data as ImageNodeData;
|
||||||
|
if (!isHttpImageUrl(imageData.src)) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: canvasNode.id,
|
||||||
|
imageUrl: imageData.src,
|
||||||
|
title: getImageNodeTitle(imageData, canvasNode.id),
|
||||||
|
origin: "canvas" as const,
|
||||||
|
sourceNodeId: canvasNode.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
[allNodes]
|
||||||
|
);
|
||||||
|
const initialTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(() => {
|
||||||
|
const sourceByNodeId = new Map(
|
||||||
|
availableTemplateReplaceSources.map((source) => [source.sourceNodeId, source])
|
||||||
|
);
|
||||||
|
const currentSource = sourceByNodeId.get(node.id) ||
|
||||||
|
(isHttpImageUrl(data.src)
|
||||||
|
? {
|
||||||
|
id: node.id,
|
||||||
|
imageUrl: data.src,
|
||||||
|
title: displayTitle,
|
||||||
|
origin: "canvas" as const,
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
return [
|
||||||
|
currentSource,
|
||||||
|
...selectedNodeIds
|
||||||
|
.filter((selectedNodeId) => selectedNodeId !== node.id)
|
||||||
|
.map((selectedNodeId) => sourceByNodeId.get(selectedNodeId) || null),
|
||||||
|
].filter((source): source is CanvasTemplateReplaceSource => Boolean(source));
|
||||||
|
}, [
|
||||||
|
availableTemplateReplaceSources,
|
||||||
|
data.src,
|
||||||
|
displayTitle,
|
||||||
|
node.id,
|
||||||
|
selectedNodeIds,
|
||||||
|
]);
|
||||||
const agentExecutionSnapshotId =
|
const agentExecutionSnapshotId =
|
||||||
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
||||||
? data.metadata.agentExecutionSnapshotId.trim()
|
? data.metadata.agentExecutionSnapshotId.trim()
|
||||||
@ -6098,53 +6113,18 @@ export function ImageNode({
|
|||||||
await detectTextItems(true);
|
await detectTextItems(true);
|
||||||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||||||
|
|
||||||
const handleOpenPsdTemplateReplace = useCallback(() => {
|
const handleOpenTemplateReplace = useCallback(() => {
|
||||||
if (!canvasId) {
|
if (!canvasId) {
|
||||||
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
|
toast.error("当前图片未绑定画布,暂时无法进行模版替换");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data.src || !isHttpImageUrl(data.src)) {
|
if (!data.src || !isHttpImageUrl(data.src)) {
|
||||||
toast.error("图片上传完成后再进行模版替换");
|
toast.error("图片上传完成后再进行模版替换");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPsdTemplateDialogOpen(true);
|
setTemplateReplaceDialogOpen(true);
|
||||||
}, [canvasId, data.src]);
|
}, [canvasId, data.src]);
|
||||||
|
|
||||||
const handleLaunchPsdTemplateReplace = useCallback(() => {
|
|
||||||
const normalizedBrandName = psdTemplateBrandName.trim();
|
|
||||||
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
|
|
||||||
toast.error("当前图片暂时无法进行模版替换");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!normalizedBrandName) {
|
|
||||||
toast.error("请先选择或输入品牌名");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = createCanvasPsdTemplateLaunchToken();
|
|
||||||
const returnTo =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? `${window.location.pathname}${window.location.search}`
|
|
||||||
: `/canvas/${canvasId}`;
|
|
||||||
saveCanvasPsdTemplateLaunchPayload(token, {
|
|
||||||
brandName: normalizedBrandName,
|
|
||||||
pendingImages: [
|
|
||||||
{
|
|
||||||
id: node.id,
|
|
||||||
imageUrl: data.src,
|
|
||||||
title: displayTitle,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeImageId: node.id,
|
|
||||||
returnTo,
|
|
||||||
templateMode: "main",
|
|
||||||
createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setPsdTemplateDialogOpen(false);
|
|
||||||
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
|
|
||||||
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
||||||
return;
|
return;
|
||||||
@ -6167,14 +6147,14 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageNodeActionRequest.action === "psd-template") {
|
if (imageNodeActionRequest.action === "template-replace") {
|
||||||
handleOpenPsdTemplateReplace();
|
handleOpenTemplateReplace();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
clearImageNodeActionRequest,
|
clearImageNodeActionRequest,
|
||||||
handleEnterBoxCutoutMode,
|
handleEnterBoxCutoutMode,
|
||||||
handleEnterLocalEditMode,
|
handleEnterLocalEditMode,
|
||||||
handleOpenPsdTemplateReplace,
|
handleOpenTemplateReplace,
|
||||||
handleOpenTextEdit,
|
handleOpenTextEdit,
|
||||||
imageNodeActionRequest,
|
imageNodeActionRequest,
|
||||||
node.id,
|
node.id,
|
||||||
@ -6926,8 +6906,8 @@ export function ImageNode({
|
|||||||
handleEnterLocalEditMode();
|
handleEnterLocalEditMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "psd-template") {
|
if (action === "template-replace") {
|
||||||
handleOpenPsdTemplateReplace();
|
handleOpenTemplateReplace();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "scale-adjust") {
|
if (action === "scale-adjust") {
|
||||||
@ -7098,6 +7078,55 @@ export function ImageNode({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTemplateReplaceAddToCanvas = useCallback(
|
||||||
|
(result: CanvasTemplateReplaceResult, resultIndex: number) => {
|
||||||
|
const resultImageUrl = String(result.resultImageUrl || "").trim();
|
||||||
|
if (!isHttpImageUrl(resultImageUrl)) {
|
||||||
|
throw new Error("模版替换结果地址不可用");
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = 24;
|
||||||
|
const column = resultIndex % 2;
|
||||||
|
const row = Math.floor(resultIndex / 2);
|
||||||
|
const addedNodeId = addNode({
|
||||||
|
type: "image",
|
||||||
|
position: {
|
||||||
|
x: node.position.x + node.size.width + gap + column * (node.size.width + gap),
|
||||||
|
y: node.position.y + row * (node.size.height + gap),
|
||||||
|
},
|
||||||
|
size: { width: node.size.width, height: node.size.height },
|
||||||
|
data: {
|
||||||
|
src: resultImageUrl,
|
||||||
|
status: "completed",
|
||||||
|
prompt: "模版替换结果",
|
||||||
|
title: `${result.source.title}-模版替换`,
|
||||||
|
originalSrc: result.source.imageUrl,
|
||||||
|
metadata: {
|
||||||
|
operation: "canvas-template-replace-batch",
|
||||||
|
batchId: result.batchId,
|
||||||
|
taskId: result.taskId,
|
||||||
|
sourceNodeId: result.source.sourceNodeId,
|
||||||
|
sourceImageUrl: result.source.imageUrl,
|
||||||
|
referenceImageUrl: result.referenceImageUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: result.source.sourceNodeId
|
||||||
|
? [result.source.sourceNodeId]
|
||||||
|
: [node.id],
|
||||||
|
});
|
||||||
|
selectNode(addedNodeId);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
addNode,
|
||||||
|
node.id,
|
||||||
|
node.position.x,
|
||||||
|
node.position.y,
|
||||||
|
node.size.height,
|
||||||
|
node.size.width,
|
||||||
|
selectNode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const handleLayerSplitComplete = useCallback(
|
const handleLayerSplitComplete = useCallback(
|
||||||
(
|
(
|
||||||
layers: Array<{
|
layers: Array<{
|
||||||
@ -9695,14 +9724,14 @@ export function ImageNode({
|
|||||||
{imageTextEditItem.label}
|
{imageTextEditItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imagePsdTemplateReplaceEnabled ? (
|
{imageTemplateReplaceEnabled ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
|
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={FileImage}
|
fallback={FileImage}
|
||||||
item={imagePsdTemplateReplaceItem}
|
item={imageTemplateReplaceItem}
|
||||||
/>
|
/>
|
||||||
<span>{imagePsdTemplateReplaceItem.label}</span>
|
<span>{imageTemplateReplaceItem.label}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imageExtendEnabled ? (
|
{imageExtendEnabled ? (
|
||||||
@ -9856,89 +9885,16 @@ export function ImageNode({
|
|||||||
prompt={regeneratePrompt}
|
prompt={regeneratePrompt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
|
<CanvasTemplateReplaceDialog
|
||||||
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
|
availableSources={availableTemplateReplaceSources}
|
||||||
<DialogHeader>
|
canvasId={canvasId}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
initialSources={initialTemplateReplaceSources}
|
||||||
<FileImage className="h-5 w-5" />
|
onAddToCanvas={handleTemplateReplaceAddToCanvas}
|
||||||
模版替换
|
onOpenChange={setTemplateReplaceDialogOpen}
|
||||||
</DialogTitle>
|
open={templateReplaceDialogOpen}
|
||||||
<DialogDescription>
|
projectId={projectId}
|
||||||
当前图片会作为替换素材带入 PSD 模版工作台。
|
triggerNodeId={node.id}
|
||||||
</DialogDescription>
|
/>
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
|
|
||||||
<img
|
|
||||||
alt={displayTitle}
|
|
||||||
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
|
|
||||||
src={buildImageProxyUrl(data.src, { width: 240 })}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 self-center">
|
|
||||||
<div className="truncate font-medium text-sm">{displayTitle}</div>
|
|
||||||
<div className="mt-1 text-muted-foreground text-xs">
|
|
||||||
已选择 1 张画布图片
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoadingPsdTemplateSummary ? (
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
正在读取模版品牌...
|
|
||||||
</div>
|
|
||||||
) : availablePsdTemplateBrands.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>快捷选择</Label>
|
|
||||||
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
|
|
||||||
{availablePsdTemplateBrands.map((brand) => {
|
|
||||||
const active = psdTemplateBrandName.trim() === brand;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-background hover:bg-muted"
|
|
||||||
)}
|
|
||||||
key={brand}
|
|
||||||
onClick={() => setPsdTemplateBrandName(brand)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{brand}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
|
|
||||||
<Input
|
|
||||||
id={`psd-template-brand-${node.id}`}
|
|
||||||
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
|
|
||||||
placeholder="选择或输入已配置 PSD 模版的品牌名"
|
|
||||||
value={psdTemplateBrandName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={!psdTemplateBrandName.trim()}
|
|
||||||
onClick={handleLaunchPsdTemplateReplace}
|
|
||||||
>
|
|
||||||
<FileImage className="mr-2 h-4 w-4" />
|
|
||||||
打开模版替换
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ThreeDGenerationDialog
|
<ThreeDGenerationDialog
|
||||||
costLabel={
|
costLabel={
|
||||||
@ -10517,20 +10473,20 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isSelectedImageActionInline("psdTemplate") ? (
|
{isSelectedImageActionInline("templateReplace") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("psd-template")}
|
onClick={() => handleAction("template-replace")}
|
||||||
title={imagePsdTemplateReplaceItem.label}
|
title={imageTemplateReplaceItem.label}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={FileImage}
|
fallback={FileImage}
|
||||||
item={imagePsdTemplateReplaceItem}
|
item={imageTemplateReplaceItem}
|
||||||
/>
|
/>
|
||||||
{imagePsdTemplateReplaceItem.label}
|
{imageTemplateReplaceItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -10808,14 +10764,14 @@ export function ImageNode({
|
|||||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{isSelectedImageActionInMore("psdTemplate") ? (
|
{isSelectedImageActionInMore("templateReplace") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
|
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={FileImage}
|
fallback={FileImage}
|
||||||
item={imagePsdTemplateReplaceItem}
|
item={imageTemplateReplaceItem}
|
||||||
/>
|
/>
|
||||||
<span>{imagePsdTemplateReplaceItem.label}</span>
|
<span>{imageTemplateReplaceItem.label}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{isSelectedImageActionInMore("removeWatermark") ? (
|
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||||
|
|||||||
@ -221,10 +221,10 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
|||||||
description: "框选多个彩色区域并按区域提示词局部改图",
|
description: "框选多个彩色区域并按区域提示词局部改图",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "imagePsdTemplateReplace",
|
key: "imageTemplateReplace",
|
||||||
group: "节点工具栏",
|
group: "节点工具栏",
|
||||||
label: "模版替换",
|
label: "模版替换",
|
||||||
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
|
description: "用当前图片和最多四张补充图片批量替换目标模版中的主体",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "imageRemoveWatermark",
|
key: "imageRemoveWatermark",
|
||||||
@ -785,11 +785,16 @@ export function normalizeCanvasToolbarConfig(
|
|||||||
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
||||||
|
|
||||||
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
|
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
|
||||||
|
const legacyRawItem =
|
||||||
|
defaultItem.key === "imageTemplateReplace"
|
||||||
|
? rawItems.imagePsdTemplateReplace
|
||||||
|
: undefined;
|
||||||
|
const rawItemValue = rawItems[defaultItem.key] ?? legacyRawItem;
|
||||||
const rawItem =
|
const rawItem =
|
||||||
rawItems[defaultItem.key] &&
|
rawItemValue &&
|
||||||
typeof rawItems[defaultItem.key] === "object" &&
|
typeof rawItemValue === "object" &&
|
||||||
!Array.isArray(rawItems[defaultItem.key])
|
!Array.isArray(rawItemValue)
|
||||||
? (rawItems[defaultItem.key] as Record<string, unknown>)
|
? (rawItemValue as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
|
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
|
||||||
const iconUrl = normalizeString(rawItem.iconUrl, 2000);
|
const iconUrl = normalizeString(rawItem.iconUrl, 2000);
|
||||||
|
|||||||
@ -208,12 +208,12 @@ export const CANVAS_CAPABILITY_REGISTRY = [
|
|||||||
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
||||||
}),
|
}),
|
||||||
defineCapability({
|
defineCapability({
|
||||||
id: "image.psd_template_replace",
|
id: "image.template_replace",
|
||||||
title: "模版替换",
|
title: "模版替换",
|
||||||
description: "把选中图片带入已配置的 PSD 模版工作台进行素材替换。",
|
description: "使用选中图片与目标模版图执行批量主体替换。",
|
||||||
clientAction: "psd-template",
|
clientAction: "template-replace",
|
||||||
toolbarActionKey: "psdTemplate",
|
toolbarActionKey: "templateReplace",
|
||||||
toolbarSettingKey: "imagePsdTemplateReplace",
|
toolbarSettingKey: "imageTemplateReplace",
|
||||||
executionMode: "interactive",
|
executionMode: "interactive",
|
||||||
risk: "write",
|
risk: "write",
|
||||||
modelPolicy: "none",
|
modelPolicy: "none",
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
|
|||||||
"image.text_edit",
|
"image.text_edit",
|
||||||
"image.box_cutout",
|
"image.box_cutout",
|
||||||
"image.local_edit",
|
"image.local_edit",
|
||||||
"image.psd_template_replace",
|
"image.template_replace",
|
||||||
"image.scale_adjust",
|
"image.scale_adjust",
|
||||||
"image.remove_watermark",
|
"image.remove_watermark",
|
||||||
"image.resize",
|
"image.resize",
|
||||||
@ -39,7 +39,7 @@ export type CanvasCapabilityClientAction =
|
|||||||
| "text-edit"
|
| "text-edit"
|
||||||
| "box-cutout"
|
| "box-cutout"
|
||||||
| "local-edit"
|
| "local-edit"
|
||||||
| "psd-template"
|
| "template-replace"
|
||||||
| "scale-adjust"
|
| "scale-adjust"
|
||||||
| "watermark"
|
| "watermark"
|
||||||
| "resize"
|
| "resize"
|
||||||
@ -65,7 +65,7 @@ export type SelectedImageToolbarActionKey =
|
|||||||
| "textEdit"
|
| "textEdit"
|
||||||
| "boxCutout"
|
| "boxCutout"
|
||||||
| "localEdit"
|
| "localEdit"
|
||||||
| "psdTemplate"
|
| "templateReplace"
|
||||||
| "removeWatermark"
|
| "removeWatermark"
|
||||||
| "resize"
|
| "resize"
|
||||||
| "multiAngle"
|
| "multiAngle"
|
||||||
|
|||||||
79
lib/canvas/template-replace-batch.ts
Normal file
79
lib/canvas/template-replace-batch.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
export const MAX_CANVAS_TEMPLATE_REPLACE_SOURCES = 5;
|
||||||
|
|
||||||
|
export type CanvasTemplateReplaceSource = {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
origin: "canvas" | "upload";
|
||||||
|
sourceNodeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSource(
|
||||||
|
source: CanvasTemplateReplaceSource | null | undefined
|
||||||
|
): CanvasTemplateReplaceSource | null {
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const imageUrl = String(source.imageUrl || "").trim();
|
||||||
|
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(source.id || imageUrl).trim() || imageUrl,
|
||||||
|
imageUrl,
|
||||||
|
title: String(source.title || "替换素材").trim() || "替换素材",
|
||||||
|
origin: source.origin === "upload" ? "upload" : "canvas",
|
||||||
|
sourceNodeId: String(source.sourceNodeId || "").trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCanvasTemplateReplaceSources(
|
||||||
|
sources: Array<CanvasTemplateReplaceSource | null | undefined>
|
||||||
|
): CanvasTemplateReplaceSource[] {
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
const normalized: CanvasTemplateReplaceSource[] = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const nextSource = normalizeSource(source);
|
||||||
|
if (!nextSource || seenUrls.has(nextSource.imageUrl)) continue;
|
||||||
|
seenUrls.add(nextSource.imageUrl);
|
||||||
|
normalized.push(nextSource);
|
||||||
|
if (normalized.length >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCanvasTemplateReplaceSlots(
|
||||||
|
sources: Array<CanvasTemplateReplaceSource | null | undefined>
|
||||||
|
): Array<CanvasTemplateReplaceSource | null> {
|
||||||
|
const slots = normalizeCanvasTemplateReplaceSources(sources);
|
||||||
|
return Array.from(
|
||||||
|
{ length: MAX_CANVAS_TEMPLATE_REPLACE_SOURCES },
|
||||||
|
(_, index) => slots[index] || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceCanvasTemplateSourceSlot(
|
||||||
|
slots: Array<CanvasTemplateReplaceSource | null>,
|
||||||
|
slotIndex: number,
|
||||||
|
source: CanvasTemplateReplaceSource | null
|
||||||
|
): Array<CanvasTemplateReplaceSource | null> {
|
||||||
|
const nextSlots = createCanvasTemplateReplaceSlots([]);
|
||||||
|
slots.slice(0, MAX_CANVAS_TEMPLATE_REPLACE_SOURCES).forEach((item, index) => {
|
||||||
|
nextSlots[index] = normalizeSource(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (slotIndex < 0 || slotIndex >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) {
|
||||||
|
return nextSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSource = normalizeSource(source);
|
||||||
|
if (normalizedSource) {
|
||||||
|
nextSlots.forEach((item, index) => {
|
||||||
|
if (index !== slotIndex && item?.imageUrl === normalizedSource.imageUrl) {
|
||||||
|
nextSlots[index] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nextSlots[slotIndex] = normalizedSource;
|
||||||
|
return nextSlots;
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
/routes\/canvas-chat-helpers\.test\.ts/,
|
/routes\/canvas-chat-helpers\.test\.ts/,
|
||||||
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
||||||
/routes\/canvas-agent-operations\.test\.ts/,
|
/routes\/canvas-agent-operations\.test\.ts/,
|
||||||
|
/routes\/canvas-capability-runtime\.test\.ts/,
|
||||||
/routes\/canvas-overlay-density\.test\.ts/,
|
/routes\/canvas-overlay-density\.test\.ts/,
|
||||||
/routes\/image-message-contracts\.test\.ts/,
|
/routes\/image-message-contracts\.test\.ts/,
|
||||||
/routes\/production-local-fallbacks\.test\.ts/,
|
/routes\/production-local-fallbacks\.test\.ts/,
|
||||||
@ -13,7 +14,7 @@ export default defineConfig({
|
|||||||
/routes\/settings-visual-consistency\.test\.ts/,
|
/routes\/settings-visual-consistency\.test\.ts/,
|
||||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||||
/routes\/canvas-psd-template-entry\.test\.ts/,
|
/routes\/canvas-template-replace-entry\.test\.ts/,
|
||||||
/routes\/model-classification\.test\.ts/,
|
/routes\/model-classification\.test\.ts/,
|
||||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
|
|||||||
@ -38,8 +38,13 @@ expectIncludes(
|
|||||||
);
|
);
|
||||||
expectIncludes(
|
expectIncludes(
|
||||||
helpersConfig,
|
helpersConfig,
|
||||||
"/routes\\/canvas-psd-template-entry\\.test\\.ts/",
|
"/routes\\/canvas-template-replace-entry\\.test\\.ts/",
|
||||||
"canvas PSD template regression target"
|
"canvas template replacement regression target"
|
||||||
|
);
|
||||||
|
expectIncludes(
|
||||||
|
helpersConfig,
|
||||||
|
"/routes\\/canvas-capability-runtime\\.test\\.ts/",
|
||||||
|
"canvas capability runtime regression target"
|
||||||
);
|
);
|
||||||
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
|
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
|
||||||
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");
|
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
|
|||||||
"textEdit",
|
"textEdit",
|
||||||
"boxCutout",
|
"boxCutout",
|
||||||
"localEdit",
|
"localEdit",
|
||||||
"psdTemplate",
|
"templateReplace",
|
||||||
"removeWatermark",
|
"removeWatermark",
|
||||||
"resize",
|
"resize",
|
||||||
"multiAngle",
|
"multiAngle",
|
||||||
|
|||||||
@ -47,6 +47,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
defaultCanvasToolbarConfig,
|
defaultCanvasToolbarConfig,
|
||||||
getCanvasToolbarItem,
|
getCanvasToolbarItem,
|
||||||
|
normalizeCanvasToolbarConfig,
|
||||||
} from "@/lib/canvas-toolbar-config";
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
|
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
|
||||||
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
|
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
|
||||||
@ -4875,11 +4876,11 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the PSD template replacement action enabled by default", () => {
|
test("keeps the canvas template replacement action enabled by default", () => {
|
||||||
expect(
|
expect(
|
||||||
getCanvasToolbarItem(
|
getCanvasToolbarItem(
|
||||||
defaultCanvasToolbarConfig,
|
defaultCanvasToolbarConfig,
|
||||||
"imagePsdTemplateReplace"
|
"imageTemplateReplace"
|
||||||
)
|
)
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
label: "模版替换",
|
label: "模版替换",
|
||||||
@ -4887,6 +4888,24 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("migrates the legacy PSD toolbar setting into canvas template replacement", () => {
|
||||||
|
const normalized = normalizeCanvasToolbarConfig({
|
||||||
|
items: {
|
||||||
|
imagePsdTemplateReplace: {
|
||||||
|
enabled: false,
|
||||||
|
label: "旧模版入口",
|
||||||
|
iconUrl: "https://example.com/template-icon.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.items.imageTemplateReplace).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
label: "旧模版入口",
|
||||||
|
iconUrl: "https://example.com/template-icon.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("syncs image notes into structured canvas annotations", () => {
|
test("syncs image notes into structured canvas annotations", () => {
|
||||||
const first = syncCanvasNodeNoteAnnotationMetadata({
|
const first = syncCanvasNodeNoteAnnotationMetadata({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
function readSource(path: string) {
|
|
||||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("canvas PSD template replacement entry", () => {
|
|
||||||
test("keeps the selected-image toolbar action and launch flow wired", () => {
|
|
||||||
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
|
||||||
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
|
||||||
|
|
||||||
expect(imageNode).toContain(
|
|
||||||
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imagePsdTemplateReplace"'
|
|
||||||
);
|
|
||||||
expect(imageNode).toContain('isSelectedImageActionInline("psdTemplate")');
|
|
||||||
expect(imageNode).toContain('isSelectedImageActionInMore("psdTemplate")');
|
|
||||||
expect(imageNode).toContain('handleAction("psd-template")');
|
|
||||||
expect(imageNode).toContain("handleOpenPsdTemplateReplace");
|
|
||||||
expect(imageNode).toContain("/psd-template?launch=");
|
|
||||||
expect(workspace).toContain(
|
|
||||||
'requestImageNodeAction(selectedImageNodes[0].id, "psd-template")'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
73
tests/routes/canvas-template-replace-entry.test.ts
Normal file
73
tests/routes/canvas-template-replace-entry.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
createCanvasTemplateReplaceSlots,
|
||||||
|
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
|
||||||
|
replaceCanvasTemplateSourceSlot,
|
||||||
|
type CanvasTemplateReplaceSource,
|
||||||
|
} from "@/lib/canvas/template-replace-batch";
|
||||||
|
|
||||||
|
function readSource(path: string) {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function source(id: string): CanvasTemplateReplaceSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
imageUrl: `https://example.com/${id}.png`,
|
||||||
|
title: id,
|
||||||
|
origin: "canvas",
|
||||||
|
sourceNodeId: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("canvas template replacement entry", () => {
|
||||||
|
test("keeps the selected-image toolbar action wired to the in-canvas batch dialog", () => {
|
||||||
|
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
||||||
|
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
||||||
|
const dialog = readSource("components/canvas/CanvasTemplateReplaceDialog.tsx");
|
||||||
|
|
||||||
|
expect(imageNode).toContain(
|
||||||
|
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageTemplateReplace"'
|
||||||
|
);
|
||||||
|
expect(imageNode).toContain('isSelectedImageActionInline("templateReplace")');
|
||||||
|
expect(imageNode).toContain('isSelectedImageActionInMore("templateReplace")');
|
||||||
|
expect(imageNode).toContain('handleAction("template-replace")');
|
||||||
|
expect(imageNode).toContain("<CanvasTemplateReplaceDialog");
|
||||||
|
expect(imageNode).not.toContain("/psd-template?launch=");
|
||||||
|
expect(imageNode).not.toContain("/api/psd-templates?summary=1");
|
||||||
|
expect(workspace).toContain(
|
||||||
|
'requestImageNodeAction(selectedImageNodes[0].id, "template-replace")'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dialog).toContain("MAX_CANVAS_TEMPLATE_REPLACE_SOURCES");
|
||||||
|
expect(dialog).toContain("imageGenerationService.layoutReplace");
|
||||||
|
expect(dialog).toContain("for (const resultId of orderedIds)");
|
||||||
|
expect(dialog).toContain('return "等待上一张"');
|
||||||
|
expect(dialog).toContain("排队中(前方");
|
||||||
|
expect(dialog).toContain("全部添加到画布");
|
||||||
|
expect(dialog).toContain("canvas-template-replace:");
|
||||||
|
expect(dialog).toContain("max-h-[calc(100dvh-1rem)]");
|
||||||
|
expect(dialog).toContain("min-h-0 flex-1 overflow-y-auto");
|
||||||
|
expect(dialog).toContain("shrink-0 gap-2 border-t");
|
||||||
|
expect(dialog).not.toContain("PSD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps five fixed slots and deduplicates a source moved between slots", () => {
|
||||||
|
const slots = createCanvasTemplateReplaceSlots([
|
||||||
|
source("a"),
|
||||||
|
source("b"),
|
||||||
|
source("c"),
|
||||||
|
source("d"),
|
||||||
|
source("e"),
|
||||||
|
source("f"),
|
||||||
|
]);
|
||||||
|
expect(slots).toHaveLength(MAX_CANVAS_TEMPLATE_REPLACE_SOURCES);
|
||||||
|
expect(slots.map((item) => item?.id)).toEqual(["a", "b", "c", "d", "e"]);
|
||||||
|
|
||||||
|
const moved = replaceCanvasTemplateSourceSlot(slots, 4, source("a"));
|
||||||
|
expect(moved[0]).toBeNull();
|
||||||
|
expect(moved[4]?.id).toBe("a");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user