ai-chatbot/components/canvas/world3d/WorldSharePage.tsx

372 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
Box,
Download,
ImageIcon,
Link2,
Sparkles,
Waypoints,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { World3DViewMode } from "@/types/canvas";
type SharedWorldPageProps = {
title: string;
prompt?: string | null;
caption?: string | null;
model?: string | null;
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
previewImageUrl?: string | null;
semanticsMetadata?: Record<string, unknown> | null;
shareUrl: string;
worlds?: SharedWorldItem[];
};
type SharedWorldItem = {
id: string;
title: string;
prompt?: string | null;
caption?: string | null;
model?: string | null;
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
previewImageUrl?: string | null;
semanticsMetadata?: Record<string, unknown> | null;
};
const VIEW_OPTIONS: Array<{
id: World3DViewMode;
label: string;
icon: typeof Sparkles;
}> = [
{ id: "splat", label: "World", icon: Sparkles },
{ id: "image", label: "图像", icon: ImageIcon },
{ id: "mesh", label: "Mesh", icon: Box },
];
function resolveAvailableViews(params: {
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
}) {
const views: World3DViewMode[] = [];
if (params.splatUrl) views.push("splat");
if (params.imageUrl) views.push("image");
if (params.meshUrl) views.push("mesh");
return views;
}
function inferInitialView(views: World3DViewMode[]) {
if (views.includes("splat")) return "splat";
if (views.includes("image")) return "image";
if (views.includes("mesh")) return "mesh";
return "image";
}
export function WorldSharePage({
title,
prompt,
caption,
model,
worldUrl,
imageUrl,
meshUrl,
splatUrl,
previewImageUrl,
semanticsMetadata,
shareUrl,
worlds,
}: SharedWorldPageProps) {
const worldItems = useMemo<SharedWorldItem[]>(
() =>
worlds?.length
? worlds
: [
{
id: "shared-world",
title,
prompt,
caption,
model,
worldUrl,
imageUrl,
meshUrl,
splatUrl,
previewImageUrl,
semanticsMetadata,
},
],
[
caption,
imageUrl,
meshUrl,
model,
previewImageUrl,
prompt,
semanticsMetadata,
splatUrl,
title,
worldUrl,
worlds,
]
);
const [activeWorldId, setActiveWorldId] = useState(
() => worldItems[0]?.id || "shared-world"
);
const activeWorld =
worldItems.find((item) => item.id === activeWorldId) || worldItems[0];
const availableViews = useMemo(
() =>
resolveAvailableViews({
worldUrl: activeWorld?.worldUrl,
imageUrl: activeWorld?.imageUrl,
meshUrl: activeWorld?.meshUrl,
splatUrl: activeWorld?.splatUrl,
}),
[activeWorld?.imageUrl, activeWorld?.meshUrl, activeWorld?.splatUrl, activeWorld?.worldUrl]
);
const [activeView, setActiveView] = useState<World3DViewMode>(
inferInitialView(availableViews)
);
const [firstPerson, setFirstPerson] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (worldItems.some((item) => item.id === activeWorldId)) return;
setActiveWorldId(worldItems[0]?.id || "shared-world");
}, [activeWorldId, worldItems]);
useEffect(() => {
if (availableViews.includes(activeView)) return;
setActiveView(inferInitialView(availableViews));
}, [activeView, availableViews]);
useEffect(() => {
setFirstPerson(true);
}, [activeWorld?.id]);
const primaryDescription =
activeWorld?.caption || activeWorld?.prompt || "这个 3D 世界已开启只读分享。";
const activeTitle = activeWorld?.title || title;
const activeMeshUrl = activeWorld?.meshUrl || null;
const handleCopyShareUrl = async () => {
try {
const absoluteUrl = new URL(shareUrl, window.location.origin).toString();
await navigator.clipboard.writeText(absoluteUrl);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
};
const handleDownloadMesh = () => {
if (!activeMeshUrl) return;
const link = document.createElement("a");
link.href = activeMeshUrl;
link.download = `shared-world-${activeWorld?.id?.slice(0, 8) || "asset"}.glb`;
link.click();
};
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(24,24,27,0.96),_rgba(2,6,23,1)_68%)] text-white">
<div className="mx-auto flex min-h-screen w-full max-w-[1600px] flex-col gap-4 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
{worldItems.length > 1 ? `${worldItems.length} 个 3D 世界分享` : "3D 世界分享"}
</Badge>
{availableViews.map((view) => (
<Badge className="border-white/10 bg-white/5 text-zinc-300" key={view} variant="outline">
{VIEW_OPTIONS.find((item) => item.id === view)?.label || view.toUpperCase()}
</Badge>
))}
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">
{activeTitle}
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-300">
{primaryDescription}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
onClick={handleCopyShareUrl}
variant="outline"
>
<Link2 className="mr-2 h-4 w-4" />
{copied ? "已复制链接" : "复制分享链接"}
</Button>
{activeMeshUrl ? (
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
onClick={handleDownloadMesh}
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
下载 Mesh
</Button>
) : null}
</div>
</div>
<div className="grid flex-1 gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="overflow-hidden rounded-3xl border border-white/10 bg-black/35 shadow-2xl backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
{VIEW_OPTIONS.filter((item) => availableViews.includes(item.id)).map((item) => (
<Button
className={cn(
"rounded-full border border-white/10 px-4",
activeView === item.id
? "bg-white text-black hover:bg-zinc-100"
: "bg-white/5 text-zinc-100 hover:bg-white/10"
)}
key={item.id}
onClick={() => {
setActiveView(item.id);
setFirstPerson(item.id === "splat");
}}
size="sm"
variant="ghost"
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Button>
))}
</div>
{activeView === "splat" && activeWorld?.splatUrl ? (
<Button
className={cn(
"rounded-full border border-white/10",
firstPerson
? "bg-amber-400 text-black hover:bg-amber-300"
: "bg-white/5 text-zinc-100 hover:bg-white/10"
)}
onClick={() => setFirstPerson((prev) => !prev)}
size="sm"
variant="ghost"
>
<Waypoints className="mr-2 h-4 w-4" />
{firstPerson ? "退出第一人称" : "第一人称"}
</Button>
) : null}
</div>
<div className="h-[62vh] min-h-[520px]">
<World3DViewer
key={`${activeWorld?.id || "shared-world"}-${activeView}`}
className="h-full w-full"
firstPerson={firstPerson}
imageUrl={activeWorld?.imageUrl || null}
interactionMode="navigate"
meshUrl={activeWorld?.meshUrl || null}
mode={activeView}
previewImageUrl={activeWorld?.previewImageUrl || null}
semanticsMetadata={activeWorld?.semanticsMetadata || null}
showInfoOverlay={false}
showMovementControls={activeView === "splat" || activeView === "mesh"}
splatUrl={activeWorld?.splatUrl || null}
worldUrl={activeWorld?.worldUrl || null}
/>
</div>
</div>
<div className="space-y-4">
{worldItems.length > 1 ? (
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
<h2 className="text-sm font-medium text-white">世界列表</h2>
<div className="mt-3 space-y-2">
{worldItems.map((item, index) => {
const thumbnail = item.previewImageUrl || item.imageUrl || null;
const isActive = item.id === activeWorld?.id;
return (
<button
className={cn(
"flex w-full items-center gap-3 rounded-2xl border border-white/10 bg-white/5 p-2 text-left transition hover:bg-white/10",
isActive && "border-cyan-300/60 bg-cyan-400/10"
)}
key={item.id}
onClick={() => setActiveWorldId(item.id)}
type="button"
>
<div className="flex h-14 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white/10">
{thumbnail ? (
<img
alt={item.title}
className="h-full w-full object-cover"
src={thumbnail}
/>
) : (
<Box className="h-5 w-5 text-zinc-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] text-cyan-200">世界 {index + 1}</div>
<div className="truncate text-sm font-medium text-white">
{item.title}
</div>
<div className="mt-1 truncate text-xs text-zinc-400">
{item.caption || item.prompt || item.model || "暂无描述"}
</div>
</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
<h2 className="text-sm font-medium text-white">查看说明</h2>
<div className="mt-3 space-y-2 text-sm leading-6 text-zinc-300">
<p>`World` 视图最接近原生 3D 世界体验,支持左键转向、右键平移、滚轮缩放,以及方向键或 `WASD` 六向移动。</p>
<p>当前页面是只读分享页,不会修改原项目或原画布内容。</p>
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
<h2 className="text-sm font-medium text-white">资源状态</h2>
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-zinc-300">
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">World</div>
<div className="mt-1">{activeWorld?.splatUrl ? "可查看" : "暂无"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">图像</div>
<div className="mt-1">
{activeWorld?.imageUrl || activeWorld?.previewImageUrl ? "可查看" : "暂无"}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">Mesh</div>
<div className="mt-1">{activeWorld?.meshUrl ? "可下载" : "暂无"}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}