feat: 3D生成面板新增四模式与批量图生入口
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-28 22:52:24 +08:00
parent 77324a79dc
commit 0c9f152128

View File

@ -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<File | null>(null);
const [referencePreviewUrl, setReferencePreviewUrl] = useState<string | null>(null);
const [multiViewSlots, setMultiViewSlots] = useState<MultiViewSlot[]>(() => createMultiViewSlots());
const [batchImageSlots, setBatchImageSlots] = useState<MultiViewSlot[]>(() =>
createBatchImageSlots()
);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
@ -320,6 +337,7 @@ export default function CanvasModel3DPage() {
const [isolatedLayerId, setIsolatedLayerId] = useState<string | null>(null);
const pollAttemptsRef = useRef(0);
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
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<string, any> = {
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() {
<CardDescription>补齐多视图与高级参数</CardDescription>
</CardHeader>
<CardContent className="space-y-4 overflow-y-auto xl:min-h-0 xl:flex-1">
<div className="grid grid-cols-3 gap-1 rounded-lg border bg-muted/30 p-1">
<Button
variant={mode === "text" ? "default" : "ghost"}
className="h-8 px-1 text-xs"
onClick={() => setMode("text")}
>
文生3D
</Button>
<Button
variant={mode === "image" ? "default" : "ghost"}
className="h-8 px-1 text-xs"
onClick={() => setMode("image")}
>
图生3D
</Button>
<Button
variant={mode === "multiview" ? "default" : "ghost"}
className="h-8 px-1 text-xs"
onClick={() => setMode("multiview")}
>
多视图
</Button>
<div className="space-y-2 rounded-xl border bg-muted/30 p-2">
<div className="grid grid-cols-4 gap-1 rounded-full bg-background/80 p-1">
<Button
variant={mode === "image" ? "default" : "ghost"}
size="icon"
className="h-9 rounded-full"
onClick={() => setMode("image")}
>
<ImagePlus className="h-4 w-4" />
</Button>
<Button
variant={mode === "multiview" ? "default" : "ghost"}
size="icon"
className="h-9 rounded-full"
onClick={() => setMode("multiview")}
>
<Box className="h-4 w-4" />
</Button>
<Button
variant={mode === "batch" ? "default" : "ghost"}
size="icon"
className="h-9 rounded-full"
onClick={() => setMode("batch")}
>
<Images className="h-4 w-4" />
</Button>
<Button
variant={mode === "text" ? "default" : "ghost"}
size="icon"
className="h-9 rounded-full"
onClick={() => setMode("text")}
>
<PenLine className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 text-center text-[11px] text-muted-foreground">
<span>图生</span>
<span>多视图</span>
<span>批量</span>
<span>文生</span>
</div>
</div>
<div className="space-y-2">
@ -1147,6 +1270,87 @@ export default function CanvasModel3DPage() {
</div>
)}
{mode === "batch" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="model3d-batch-upload">批量参考图(最多 10 张)</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setBatchFiles([])}
>
清空全部
</Button>
</div>
<label
htmlFor="model3d-batch-upload"
className={cn(
"flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed px-3 py-4 text-xs transition-colors",
"hover:bg-muted/50"
)}
>
<Boxes className="h-4 w-4" />
<span>批量上传 PNG/JPG/WEBP(每张 &lt;=10MB)</span>
</label>
<Input
id="model3d-batch-upload"
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
className="hidden"
onChange={(event) => {
handleBatchPickerChange(event.target.files);
event.target.value = "";
}}
/>
<div className="grid grid-cols-2 gap-2">
{batchImageSlots.map((slot, index) => (
<div key={slot.id} className="space-y-1">
<label
htmlFor={`batch-slot-${slot.id}`}
className={cn(
"relative flex aspect-square cursor-pointer items-center justify-center rounded-md border border-dashed text-xs transition-colors",
slot.previewUrl ? "overflow-hidden border-solid" : "hover:bg-muted/50"
)}
>
{slot.previewUrl ? (
<img
src={slot.previewUrl}
alt={slot.file?.name || `批量${index + 1}`}
className="h-full w-full object-cover"
/>
) : (
<span className="text-muted-foreground">图 {index + 1}</span>
)}
<input
id={`batch-slot-${slot.id}`}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) =>
handleBatchFileChange(index, event.target.files?.[0] || null)
}
/>
</label>
{slot.previewUrl ? (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-full text-[11px]"
onClick={() => clearBatchSlot(index)}
>
移除
</Button>
) : null}
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label>模型版本</Label>
<Select value={modelVersion} onValueChange={(value) => setModelVersion(value as any)}>
@ -1210,7 +1414,7 @@ export default function CanvasModel3DPage() {
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
{(mode === "image" || mode === "multiview") && (
{(mode === "image" || mode === "multiview" || mode === "batch") && (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>纹理对齐</Label>