feat: add multi-select add-to-canvas UI for3D world/model history

- Add '批量添加到画布' panel in the right-side assets panel, with
 全选可添加 toggle and '添加到画布 (N)' action button.
- Add per-card '加画布 / 已选' toggle button (right-of-download icon),
 visible when task.status is success. Selected cards show yellow ring.
- Wire selectedAddTaskIds Set state, addableTasks/selectedAddTasks memos,
 handleToggleAddSelection / handleSelectAllForAdd / handleBatchImportToCanvas
 handlers, and a useEffect to prune stale selections when the visible
 history changes.
- handleBatchImportToCanvas calls addNodesBulk (added in store commit)
 which loops addNode and reuses its ring-scan auto-overlap logic, so
 multiple world3d/model3d nodes land at distinct canvas positions instead
 of stacking on the viewport center.
This commit is contained in:
Your Name 2026-06-08 16:26:36 +08:00
parent fdb5304111
commit c9fe549e86

View File

@ -3,45 +3,46 @@
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import dynamic from "next/dynamic";
import {
ArrowLeft,
Bone,
Box,
Boxes,
Check,
ChevronDown,
ChevronUp,
Crosshair,
Download,
Eye,
EyeOff,
Grid3X3,
Home,
ImagePlus,
Images,
Layers3,
LayoutGrid,
Loader2,
Maximize,
Package,
Palette,
PenLine,
Play,
PlusCircle,
RefreshCw,
RotateCw,
Scissors,
Settings2,
Sparkles,
Sun,
Globe,
ImageIcon,
Link2,
PanelTop,
Video,
WandSparkles,
X,
} from "lucide-react";
import {
ArrowLeft,
Bone,
Box,
Boxes,
Check,
ChevronDown,
ChevronUp,
Crosshair,
Download,
Eye,
EyeOff,
Grid3X3,
Home,
ImagePlus,
Images,
Layers3,
LayoutGrid,
Loader2,
Maximize,
Package,
Palette,
PenLine,
Play,
Plus,
PlusCircle,
RefreshCw,
RotateCw,
Scissors,
Settings2,
Sparkles,
Sun,
Globe,
ImageIcon,
Link2,
PanelTop,
Video,
WandSparkles,
X,
} from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -2283,51 +2284,255 @@ export default function CanvasModel3DPage() {
);
}, [shareableWorldTasks]);
const handleShareSelectedWorlds = useCallback(async () => {
if (selectedWorldShareTasks.length < 2) {
toast.error("请至少选择 2 个成功生成的 3D 世界");
return;
}
const handleShareSelectedWorlds = useCallback(async () => {
if (selectedWorldShareTasks.length <2) {
toast.error("请至少选择2 个成功生成的3D 世界");
return;
}
try {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskIds: selectedWorldShareTasks.map((task) => task.taskId),
shareEnabled: true,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
error?: string;
items?: TaskRecordApiEntity[];
shareToken?: string | null;
};
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "批量分享世界失败");
}
try {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskIds: selectedWorldShareTasks.map((task) => task.taskId),
shareEnabled: true,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
error?: string;
items?: TaskRecordApiEntity[];
shareToken?: string | null;
};
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "批量分享世界失败");
}
const updatedTasks = Array.isArray(payload.items)
? payload.items
.map((item) => mapApiEntityToTaskRecord(item))
.filter((item): item is TaskRecord => Boolean(item))
: [];
for (const updatedTask of updatedTasks) {
upsertTaskHistory(updatedTask, { persist: false });
}
const updatedTasks = Array.isArray(payload.items)
? payload.items
.map((item) => mapApiEntityToTaskRecord(item))
.filter((item): item is TaskRecord => Boolean(item))
: [];
for (const updatedTask of updatedTasks) {
upsertTaskHistory(updatedTask, { persist: false });
}
const shareToken = payload.shareToken || updatedTasks[0]?.shareToken;
const shareUrl = buildWorldShareUrl(shareToken);
if (!shareUrl) {
throw new Error("分享链接生成失败");
}
await navigator.clipboard.writeText(shareUrl);
toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量分享世界失败");
}
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
const shareToken = payload.shareToken || updatedTasks[0]?.shareToken;
const shareUrl = buildWorldShareUrl(shareToken);
if (!shareUrl) {
throw new Error("分享链接生成失败");
}
await navigator.clipboard.writeText(shareUrl);
toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量分享世界失败");
}
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
//多选添加到画布的 handlers
const handleToggleAddSelection = useCallback(
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
event?.stopPropagation();
if (task.status !== "success") {
toast.error("仅成功生成的世界/模型可添加到画布");
return;
}
setSelectedAddTaskIds((prev) => {
const next = new Set(prev);
if (next.has(task.taskId)) {
next.delete(task.taskId);
} else {
next.add(task.taskId);
}
return next;
});
},
[]
);
const handleSelectAllForAdd = useCallback(() => {
setSelectedAddTaskIds((prev) => {
if (prev.size === addableTasks.length) {
return new Set();
}
return new Set(addableTasks.map((task) => task.taskId));
});
}, [addableTasks]);
//清理已不存在的选中项
useEffect(() => {
const addableIds = new Set(addableTasks.map((task) => task.taskId));
setSelectedAddTaskIds((prev) => {
const next = new Set<string>();
for (const id of prev) {
if (addableIds.has(id)) next.add(id);
}
return next.size === prev.size ? prev : next;
});
}, [addableTasks]);
//批量添加到画布:为每个 task 计算节点配置,复用 addNode 的 ring-scan避让逻辑
const handleBatchImportToCanvas = useCallback(async () => {
const targets = selectedAddTasks;
if (targets.length ===0) {
toast.error("请至少选择1 个成功生成的世界/模型");
return;
}
const viewportX = viewport.x ||0;
const viewportY = viewport.y ||0;
const viewportZoom = viewport.zoom ||1;
const stageWidth =
typeof window !== "undefined" ? window.innerWidth :1280;
const stageHeight =
typeof window !== "undefined" ? window.innerHeight :720;
const targetWidth =380;
const targetHeight =300;
const baseX =
(stageWidth *0.48 - viewportX) / viewportZoom - targetWidth /2;
const baseY =
(stageHeight *0.45 - viewportY) / viewportZoom - targetHeight /2;
//收集每个 task 的节点配置(让 store内部做位置避让)
const nodeConfigs: Array<
Parameters<typeof addNode>[0]
> = [];
for (const task of targets) {
const {
canImport,
isWorldTask,
modelUrl,
primarySplatUrl,
worldAssetPayload,
worldImageUrl,
} = getTaskImportContext(task);
if (!canImport) {
continue;
}
nodeConfigs.push(
isWorldTask
? {
type: "world3d",
position: { x: baseX, y: baseY },
size: { width: targetWidth, height: targetHeight },
data: {
worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined,
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined,
previewImage:
task.previewImageUrl ||
worldAssetPayload?.thumbnailUrl ||
undefined,
prompt: task.prompt,
modelVersion: task.modelVersion,
status: "completed",
activeView: primarySplatUrl
? "splat"
: worldImageUrl
? "image"
: "mesh",
firstPerson: false,
hotspots: [],
semanticsMetadata:
worldAssetPayload?.semanticsMetadata || undefined,
metadata: {
taskId: task.taskId,
provider: task.provider,
mode: task.mode,
sourceTaskId: task.sourceTaskId,
progress: task.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: task.assetPayload ?? null,
},
},
connections: [],
}
: {
type: "model3d",
position: { x: baseX, y: baseY },
size: { width: targetWidth, height: targetHeight },
data: {
src: modelUrl!,
previewImage: task.previewImageUrl || undefined,
prompt: task.prompt,
modelVersion: task.modelVersion,
format: extractModelFormat(modelUrl!),
status: "completed",
metadata: {
taskId: task.taskId,
provider: task.provider,
mode: task.mode,
sourceTaskId: task.sourceTaskId,
progress: task.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: task.assetPayload ?? null,
},
},
connections: [],
}
);
}
if (nodeConfigs.length ===0) {
toast.error("选中的资产没有可添加到画布的内容");
return;
}
//复用 addNodesBulk(循环 addNode → 自动避让)
const createdNodeIds = addNodesBulk(nodeConfigs);
//选中新增的节点,让用户立刻看到位置
if (createdNodeIds.length >0) {
selectNodes(createdNodeIds);
}
// 清空选中状态,提示结果
setSelectedAddTaskIds(new Set());
const skipped = targets.length - nodeConfigs.length;
const worldCount = nodeConfigs.filter((c) => c.type === "world3d").length;
const modelCount = nodeConfigs.length - worldCount;
const summary = [
worldCount >0 ? `${worldCount} 个世界` : "",
modelCount >0 ? `${modelCount} 个模型` : "",
]
.filter(Boolean)
.join(" + ");
toast.success(
skipped >0
? `已批量添加 ${summary}(跳过 ${skipped} 个无资源项)`
: `已批量添加 ${summary}到画布`
);
try {
await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) {
console.error("[Model3DPage] 保存批量画布失败:", error);
}
}, [
addNode,
addNodesBulk,
getCanvasState,
getTaskImportContext,
id,
selectNodes,
selectedAddTasks,
viewerStats.meshCount,
viewerStats.triangles,
viewerStats.vertices,
viewport.x,
viewport.y,
viewport.zoom,
]);
const handleDisableCurrentShare = useCallback(async () => {
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
@ -4080,62 +4285,150 @@ export default function CanvasModel3DPage() {
<X className="h-4 w-4" />
</Button>
</div>
{workspaceMode === "world" ? (
<div className="space-y-2 border-white/10 border-b px-3 py-3">
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
<span>多世界分享</span>
<span>已选 {selectedWorldShareTasks.length}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
disabled={shareableWorldTasks.length === 0}
onClick={handleSelectAllShareableWorlds}
size="sm"
variant="outline"
>
{selectedWorldShareTaskIds.length === shareableWorldTasks.length &&
shareableWorldTasks.length > 0
? "取消全选"
: "全选世界"}
</Button>
<Button
className="h-8 border-cyan-400/30 bg-cyan-400/15 text-xs text-cyan-100 hover:bg-cyan-400/25"
disabled={selectedWorldShareTasks.length < 2}
onClick={handleShareSelectedWorlds}
size="sm"
variant="outline"
>
分享所选
</Button>
</div>
</div>
) : null}
{workspaceMode === "world" ? (
<div className="space-y-2 border-white/10 border-b px-3 py-3">
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
<span>多世界分享</span>
<span>已选 {selectedWorldShareTasks.length}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
disabled={shareableWorldTasks.length ===0}
onClick={handleSelectAllShareableWorlds}
size="sm"
variant="outline"
>
{selectedWorldShareTaskIds.length === shareableWorldTasks.length &&
shareableWorldTasks.length >0
? "取消全选"
: "全选世界"}
</Button>
<Button
className="h-8 border-cyan-400/30 bg-cyan-400/15 text-xs text-cyan-100 hover:bg-cyan-400/25"
disabled={selectedWorldShareTasks.length <2}
onClick={handleShareSelectedWorlds}
size="sm"
variant="outline"
>
分享所选
</Button>
</div>
</div>
) : null}
{addableTasks.length >0 ? (
<div className="space-y-2 border-white/10 border-b px-3 py-3">
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
<span>批量添加到画布</span>
<span>已选 {selectedAddTaskIds.size}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
disabled={addableTasks.length ===0}
onClick={handleSelectAllForAdd}
size="sm"
variant="outline"
>
{selectedAddTaskIds.size === addableTasks.length && addableTasks.length >0
? "取消全选"
: "全选可添加"}
</Button>
<Button
className="h-8 border-yellow-400/30 bg-yellow-400/15 text-xs text-yellow-100 hover:bg-yellow-400/25"
disabled={selectedAddTaskIds.size ===0}
onClick={() => void handleBatchImportToCanvas()}
size="sm"
variant="outline"
>
添加到画布 ({selectedAddTaskIds.size})
</Button>
</div>
</div>
) : null}
<ScrollArea className="flex-1">
<div className="grid grid-cols-2 gap-2 p-3">
{visibleTaskHistory.map((task) => {
const canShareWorld =
task.provider === "worldlabs-3d" && task.status === "success";
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
{visibleTaskHistory.map((task) => {
const canShareWorld =
task.provider === "worldlabs-3d" && task.status === "success";
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
const isSelectedForAdd =
task.status === "success" && selectedAddTaskIds.has(task.taskId);
const canAddToCanvas = task.status === "success";
return (
<button
className={cn(
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
currentTask?.taskId === task.taskId &&
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
isSelectedForShare &&
"border-cyan-300 ring-2 ring-cyan-300/40"
)}
key={task.taskId}
onClick={() => handleSelectTask(task)}
type="button"
>
{currentTask?.taskId === task.taskId ? (
<Badge className="absolute left-2 bottom-2 z-10 border-yellow-400/40 bg-yellow-500/20 text-[9px] text-yellow-50">
当前
</Badge>
) : null}
return (
<button
className={cn(
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
currentTask?.taskId === task.taskId &&
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
isSelectedForShare &&
"border-cyan-300 ring-2 ring-cyan-300/40",
isSelectedForAdd &&
"border-yellow-300 ring-2 ring-yellow-300/40"
)
key={task.taskId}
onClick={() => handleSelectTask(task)}
type="button"
>
{currentTask?.taskId === task.taskId ? (
<Badge className="absolute left-2 bottom-2 z-10 border-yellow-400/40 bg-yellow-500/20 text-[9px] text-yellow-50">
当前
</Badge>
) : null}
{canShareWorld ? (
<Button
className={cn(
"absolute left-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
task.shareEnabled &&
"border-amber-400/40 bg-amber-400/20 text-amber-100 opacity-100"
)
onClick={(event) => void handleShareTask(task, event)}
size="icon"
type="button"
variant="ghost"
>
<Link2 className="h-3.5 w-3.5" />
</Button>
) : null}
{canShareWorld ? (
<Button
className={cn(
"absolute left-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
isSelectedForShare &&
"border-cyan-300/60 bg-cyan-400/25 text-cyan-50 opacity-100"
)
onClick={(event) => handleToggleWorldShareSelection(task, event)}
size="sm"
type="button"
variant="ghost"
>
{isSelectedForShare ? (
<Check className="mr-1 h-3 w-3" />
) : null}
{isSelectedForShare ? "已选" : "选择"}
</Button>
) : null}
{canAddToCanvas ? (
<Button
className={cn(
"absolute right-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
isSelectedForAdd &&
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50 opacity-100"
)
onClick={(event) => handleToggleAddSelection(task, event)}
size="sm"
type="button"
variant="ghost"
>
{isSelectedForAdd ? (
<Plus className="mr-1 h-3 w-3" />
) : (
<Plus className="mr-1 h-3 w-3" />
)}
{isSelectedForAdd ? "已选" : "加画布"}
</Button>
) : null}
{canShareWorld ? (
<Button
className={cn(