185 lines
6.0 KiB
TypeScript
185 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import {
|
|
AlertCircle,
|
|
Box,
|
|
Copy,
|
|
Download,
|
|
ExternalLink,
|
|
Loader2,
|
|
MoreVertical,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { toast } from "sonner";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface Model3DNodeProps {
|
|
node: CanvasNode;
|
|
isSelected: boolean;
|
|
}
|
|
|
|
function inferModelFileName(data: Model3DNodeData, nodeId: string) {
|
|
const raw = String(data.src || "").trim();
|
|
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
|
try {
|
|
const url = new URL(raw);
|
|
const filename = url.pathname.split("/").filter(Boolean).pop();
|
|
if (filename) return decodeURIComponent(filename);
|
|
} catch {}
|
|
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
|
|
}
|
|
|
|
function inferModelFormat(data: Model3DNodeData) {
|
|
if (data.format) return data.format.toUpperCase();
|
|
const src = String(data.src || "").toLowerCase();
|
|
if (src.endsWith(".gltf")) return "GLTF";
|
|
if (src.endsWith(".glb")) return "GLB";
|
|
if (src.endsWith(".usdz")) return "USDZ";
|
|
if (src.endsWith(".fbx")) return "FBX";
|
|
if (src.endsWith(".obj")) return "OBJ";
|
|
if (src.endsWith(".stl")) return "STL";
|
|
if (src.endsWith(".3mf")) return "3MF";
|
|
return "3D";
|
|
}
|
|
|
|
export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
|
const data = node.data as Model3DNodeData;
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
|
|
|
const handleOpen = () => {
|
|
if (!data.src) {
|
|
toast.error("暂无可打开的 3D 地址");
|
|
return;
|
|
}
|
|
window.open(data.src, "_blank", "noopener,noreferrer");
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
if (!data.src) {
|
|
toast.error("暂无可下载的 3D 文件");
|
|
return;
|
|
}
|
|
const link = document.createElement("a");
|
|
link.href = data.src;
|
|
link.download = inferModelFileName(data, node.id);
|
|
link.click();
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (!data.src) {
|
|
toast.error("暂无可复制的地址");
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(data.src);
|
|
toast.success("已复制 3D 链接");
|
|
} catch {
|
|
toast.error("复制失败");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm",
|
|
isSelected && "border-primary"
|
|
)}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{data.status === "generating" ? (
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
|
|
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
|
<span className="text-xs text-muted-foreground">3D 生成中...</span>
|
|
</div>
|
|
) : data.status === "failed" ? (
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
|
|
<AlertCircle className="h-7 w-7 text-destructive" />
|
|
<span className="text-xs text-destructive">3D 生成失败</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col">
|
|
<div className="relative flex-1 overflow-hidden bg-muted/30">
|
|
{data.previewImage ? (
|
|
<img
|
|
src={data.previewImage}
|
|
alt={data.prompt || "3D 预览"}
|
|
className="h-full w-full object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<Box className="h-12 w-12 text-muted-foreground/60" />
|
|
</div>
|
|
)}
|
|
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
|
|
{inferModelFormat(data)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1 border-t px-3 py-2">
|
|
<p className="truncate text-xs font-medium">
|
|
{data.prompt || inferModelFileName(data, node.id)}
|
|
</p>
|
|
<p className="truncate text-[11px] text-muted-foreground">
|
|
{data.modelVersion ? `模型: ${data.modelVersion}` : "Tripo3D 结果"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isHovered && data.status === "completed" && (
|
|
<div className="absolute right-2 top-2 flex items-center gap-1">
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleOpen}
|
|
title="打开3D"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="secondary" size="icon" className="h-7 w-7">
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={handleOpen}>
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
打开 3D
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleDownload}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
下载
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleCopy}>
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
复制链接
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
删除
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|