From 0c9f1521289b5f8c8a3b3954f573a25b0bfcb525 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 28 Feb 2026 22:52:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=203D=E7=94=9F=E6=88=90=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=9B=E6=A8=A1=E5=BC=8F=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=9B=BE=E7=94=9F=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/canvas/[id]/model3d/page.tsx | 254 ++++++++++++++++++++++++++++--- 1 file changed, 229 insertions(+), 25 deletions(-) diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 2fa8b6331..9acee3123 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -5,13 +5,17 @@ import { useParams, useRouter } from "next/navigation"; import { ArrowLeft, Box, + Boxes, ChevronDown, ChevronUp, Download, Eye, EyeOff, + ImagePlus, + Images, Layers3, Loader2, + PenLine, PlusCircle, RefreshCw, Sparkles, @@ -41,7 +45,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas"; import { canvasService, model3DGenerationService, type Model3DTaskStatus } from "@/services/canvas"; import { cn } from "@/lib/utils"; -type GenerationMode = "text" | "image" | "multiview"; +type GenerationMode = "text" | "image" | "multiview" | "batch"; type TaskMode = | GenerationMode | "convert" @@ -181,6 +185,14 @@ function createMultiViewSlots(count = 4): MultiViewSlot[] { })); } +function createBatchImageSlots(count = 10): MultiViewSlot[] { + return Array.from({ length: count }, (_, index) => ({ + id: `batch_slot_${index + 1}`, + file: null, + previewUrl: null, + })); +} + function modeLabel(mode: TaskMode) { switch (mode) { case "text": @@ -189,6 +201,8 @@ function modeLabel(mode: TaskMode) { return "图生3D"; case "multiview": return "多视图3D"; + case "batch": + return "批量图生3D"; case "convert": return "格式转换"; case "refine": @@ -276,6 +290,9 @@ export default function CanvasModel3DPage() { const [referenceImage, setReferenceImage] = useState(null); const [referencePreviewUrl, setReferencePreviewUrl] = useState(null); const [multiViewSlots, setMultiViewSlots] = useState(() => createMultiViewSlots()); + const [batchImageSlots, setBatchImageSlots] = useState(() => + createBatchImageSlots() + ); const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); @@ -320,6 +337,7 @@ export default function CanvasModel3DPage() { const [isolatedLayerId, setIsolatedLayerId] = useState(null); const pollAttemptsRef = useRef(0); const multiViewSlotsRef = useRef([]); + const batchImageSlotsRef = useRef([]); useEffect(() => { const loadBalance = async () => { @@ -376,6 +394,10 @@ export default function CanvasModel3DPage() { multiViewSlotsRef.current = multiViewSlots; }, [multiViewSlots]); + useEffect(() => { + batchImageSlotsRef.current = batchImageSlots; + }, [batchImageSlots]); + useEffect(() => { return () => { for (const slot of multiViewSlotsRef.current) { @@ -383,6 +405,11 @@ export default function CanvasModel3DPage() { URL.revokeObjectURL(slot.previewUrl); } } + for (const slot of batchImageSlotsRef.current) { + if (slot.previewUrl) { + URL.revokeObjectURL(slot.previewUrl); + } + } }; }, []); @@ -411,7 +438,8 @@ export default function CanvasModel3DPage() { const currentPreviewImage = currentTask?.previewImageUrl || (mode === "image" ? referencePreviewUrl : null) || - multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || + (mode === "multiview" ? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl : null) || + (mode === "batch" ? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl : null) || null; useEffect(() => { @@ -506,6 +534,50 @@ export default function CanvasModel3DPage() { handleMultiViewFileChange(index, null); }; + const setBatchFiles = (files: File[]) => { + const nextFiles = files.slice(0, 10); + setBatchImageSlots((prev) => { + for (const slot of prev) { + if (slot.previewUrl) { + URL.revokeObjectURL(slot.previewUrl); + } + } + return createBatchImageSlots().map((slot, index) => { + const file = nextFiles[index] || null; + return { + ...slot, + file, + previewUrl: file ? URL.createObjectURL(file) : null, + }; + }); + }); + }; + + const handleBatchPickerChange = (files: FileList | null) => { + const nextFiles = files ? Array.from(files) : []; + setBatchFiles(nextFiles); + }; + + const handleBatchFileChange = (index: number, file: File | null) => { + setBatchImageSlots((prev) => { + const next = [...prev]; + const target = next[index]; + if (!target) return prev; + + if (target.previewUrl) URL.revokeObjectURL(target.previewUrl); + next[index] = { + ...target, + file, + previewUrl: file ? URL.createObjectURL(file) : null, + }; + return next; + }); + }; + + const clearBatchSlot = (index: number) => { + handleBatchFileChange(index, null); + }; + const buildGenerationPayload = (taskType: "text_to_model" | "image_to_model" | "multiview_to_model") => { const payload: Record = { taskType, @@ -602,10 +674,15 @@ export default function CanvasModel3DPage() { return; } const selectedMultiFiles = multiViewSlots.map((slot) => slot.file).filter(Boolean) as File[]; + const selectedBatchFiles = batchImageSlots.map((slot) => slot.file).filter(Boolean) as File[]; if (mode === "multiview" && selectedMultiFiles.length < 2) { toast.error("多视图生成至少上传 2 张图片"); return; } + if (mode === "batch" && selectedBatchFiles.length < 1) { + toast.error("批量图生请至少上传 1 张图片"); + return; + } setIsSubmitting(true); setUploadingImage(false); @@ -643,6 +720,33 @@ export default function CanvasModel3DPage() { return; } + if (mode === "batch") { + setUploadingImage(true); + let successCount = 0; + for (const file of selectedBatchFiles.slice(0, 10)) { + const uploadResult = await model3DGenerationService.uploadImage(file); + if (!uploadResult.success || !uploadResult.data) { + throw new Error(uploadResult.error || `上传失败: ${file.name}`); + } + const payload = buildGenerationPayload("image_to_model"); + if (uploadResult.data.imageToken) { + payload.imageToken = uploadResult.data.imageToken; + } else if (uploadResult.data.file) { + payload.file = uploadResult.data.file; + } else { + throw new Error(`图片未返回可用 token: ${file.name}`); + } + await submitTask({ + payload, + mode: "batch", + promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`, + }); + successCount += 1; + } + toast.success(`已提交 ${successCount} 个批量图生3D任务`); + return; + } + setUploadingImage(true); const uploadResults = await Promise.all( selectedMultiFiles.map(async (file) => model3DGenerationService.uploadImage(file)) @@ -1017,28 +1121,47 @@ export default function CanvasModel3DPage() { 补齐多视图与高级参数 -
- - - +
+
+ + + + +
+
+ 图生 + 多视图 + 批量 + 文生 +
@@ -1147,6 +1270,87 @@ export default function CanvasModel3DPage() {
)} + {mode === "batch" && ( +
+
+ + +
+ + { + handleBatchPickerChange(event.target.files); + event.target.value = ""; + }} + /> +
+ {batchImageSlots.map((slot, index) => ( +
+ + {slot.previewUrl ? ( + + ) : null} +
+ ))} +
+
+ )} +