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 (left 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 visible history changes. - handleBatchImportToCanvas calls addNodesBulk (added in 3ca5859) 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:
parent
c9fe549e86
commit
afd1f84789
@ -3,47 +3,47 @@
|
||||
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,
|
||||
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";
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Link2,
|
||||
PanelTop,
|
||||
Video,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@ -340,36 +340,6 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskImportContext(task?: TaskRecord | null) {
|
||||
const isWorldTask = task?.provider === "worldlabs-3d";
|
||||
const modelUrl = resolveTaskModelUrl(task);
|
||||
const worldAssetPayload = isWorldTask ? task?.assetPayload ?? null : null;
|
||||
const primarySplatUrl =
|
||||
isWorldTask && worldAssetPayload
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
task?.previewImageUrl ||
|
||||
null;
|
||||
const canImportWorld = Boolean(
|
||||
modelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
|
||||
return {
|
||||
canImport: Boolean(task?.status === "success") && (isWorldTask ? canImportWorld : Boolean(modelUrl)),
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `slot_${index + 1}`,
|
||||
@ -673,20 +643,9 @@ function CostPill({
|
||||
|
||||
export default function CanvasModel3DPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const routeParams = useParams();
|
||||
const id = String(routeParams?.id || "");
|
||||
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
|
||||
const returnTo = useMemo(() => {
|
||||
const raw = searchParams.get("returnTo")?.trim();
|
||||
return raw || `/canvas/${id}`;
|
||||
}, [id, searchParams]);
|
||||
const shouldReturnToVideoCanvas = useMemo(
|
||||
() =>
|
||||
searchParams.get("source") === "video-canvas" ||
|
||||
/(?:\?|&)mode=video(?:&|$)|(?:\?|&)workspaceType=video(?:&|$)/.test(returnTo),
|
||||
[returnTo, searchParams]
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
@ -770,10 +729,11 @@ export default function CanvasModel3DPage() {
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||
//多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布
|
||||
// 多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布
|
||||
const [selectedAddTaskIds, setSelectedAddTaskIds] = useState<Set<string>>(new Set());
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>( null
|
||||
);
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
@ -1176,9 +1136,7 @@ export default function CanvasModel3DPage() {
|
||||
const pending = normalized.find(
|
||||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||||
);
|
||||
const initialTask = pending || normalized[0] || null;
|
||||
setCurrentTask(initialTask);
|
||||
setWorkspaceMode(inferWorkspaceModeFromTask(initialTask));
|
||||
setCurrentTask(pending || normalized[0] || null);
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
|
||||
const migrated = await migrateLegacyHistory();
|
||||
@ -1308,18 +1266,18 @@ export default function CanvasModel3DPage() {
|
||||
return next;
|
||||
}, [isolatedLayerId, layerVisibility, viewerLayers]);
|
||||
|
||||
const visibleTaskHistory = useMemo(
|
||||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
const shareableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const visibleTaskHistory = useMemo(
|
||||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
const shareableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||||
@ -2048,40 +2006,31 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const buildReturnUrl = useCallback(
|
||||
(taskId?: string) => {
|
||||
if (typeof window === "undefined") return returnTo;
|
||||
const url = new URL(returnTo, window.location.origin);
|
||||
if (taskId) {
|
||||
url.searchParams.set("directorImport", "1");
|
||||
url.searchParams.set("directorTaskId", taskId);
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
},
|
||||
[returnTo]
|
||||
);
|
||||
|
||||
const handleImportToCanvas = async (task = currentTask) => {
|
||||
if (!task || task.status !== "success") {
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentTask || currentTask.status !== "success") {
|
||||
toast.error("暂无可添加的 3D 资产");
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldReturnToVideoCanvas) {
|
||||
router.push(buildReturnUrl(task.taskId));
|
||||
return;
|
||||
}
|
||||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||||
const worldAssetPayload = currentTask.assetPayload ?? null;
|
||||
const primarySplatUrl = isWorldTask
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
currentTask.previewImageUrl ||
|
||||
null;
|
||||
|
||||
const {
|
||||
canImport,
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
} = getTaskImportContext(task);
|
||||
const canImportWorld = Boolean(
|
||||
currentModelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
|
||||
if (!canImport) {
|
||||
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
|
||||
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
|
||||
return;
|
||||
}
|
||||
@ -2107,14 +2056,14 @@ export default function CanvasModel3DPage() {
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
task.previewImageUrl ||
|
||||
currentTask.previewImageUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl
|
||||
? "splat"
|
||||
@ -2125,15 +2074,15 @@ export default function CanvasModel3DPage() {
|
||||
hotspots: [],
|
||||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -2143,22 +2092,22 @@ export default function CanvasModel3DPage() {
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: modelUrl!,
|
||||
previewImage: task.previewImageUrl || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
format: extractModelFormat(modelUrl!),
|
||||
src: currentModelUrl!,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -2284,53 +2233,53 @@ 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
|
||||
// 多选添加到画布的 handlers
|
||||
const handleToggleAddSelection = useCallback(
|
||||
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
|
||||
event?.stopPropagation();
|
||||
@ -2372,7 +2321,7 @@ export default function CanvasModel3DPage() {
|
||||
});
|
||||
}, [addableTasks]);
|
||||
|
||||
//批量添加到画布:为每个 task 计算节点配置,复用 addNode 的 ring-scan避让逻辑
|
||||
//批量添加到画布:复用 addNodesBulk → addNode ring-scan 自动避让
|
||||
const handleBatchImportToCanvas = useCallback(async () => {
|
||||
const targets = selectedAddTasks;
|
||||
if (targets.length ===0) {
|
||||
@ -2390,29 +2339,38 @@ export default function CanvasModel3DPage() {
|
||||
const targetWidth =380;
|
||||
const targetHeight =300;
|
||||
|
||||
const baseX =
|
||||
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]
|
||||
> = [];
|
||||
const nodeConfigs: Array<Parameters<typeof addNode>[0]> = [];
|
||||
|
||||
for (const task of targets) {
|
||||
const {
|
||||
canImport,
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
} = getTaskImportContext(task);
|
||||
if (task.status !== "success") continue;
|
||||
|
||||
if (!canImport) {
|
||||
continue;
|
||||
}
|
||||
const isWorldTask = task.provider === "worldlabs-3d";
|
||||
const worldAssetPayload = task.assetPayload ?? null;
|
||||
const taskModelUrl = resolveTaskModelUrl(task);
|
||||
const primarySplatUrl = isWorldTask
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
task.previewImageUrl ||
|
||||
null;
|
||||
|
||||
const canImport = isWorldTask
|
||||
? Boolean(
|
||||
taskModelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
)
|
||||
: Boolean(taskModelUrl);
|
||||
|
||||
if (!canImport) continue;
|
||||
|
||||
nodeConfigs.push(
|
||||
isWorldTask
|
||||
@ -2423,7 +2381,7 @@ export default function CanvasModel3DPage() {
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
meshUrl: taskModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
task.previewImageUrl ||
|
||||
@ -2460,11 +2418,11 @@ export default function CanvasModel3DPage() {
|
||||
position: { x: baseX, y: baseY },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: modelUrl!,
|
||||
src: taskModelUrl!,
|
||||
previewImage: task.previewImageUrl || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
format: extractModelFormat(modelUrl!),
|
||||
format: extractModelFormat(taskModelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
@ -2488,15 +2446,12 @@ export default function CanvasModel3DPage() {
|
||||
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;
|
||||
@ -2522,7 +2477,6 @@ export default function CanvasModel3DPage() {
|
||||
addNode,
|
||||
addNodesBulk,
|
||||
getCanvasState,
|
||||
getTaskImportContext,
|
||||
id,
|
||||
selectNodes,
|
||||
selectedAddTasks,
|
||||
@ -2534,7 +2488,7 @@ export default function CanvasModel3DPage() {
|
||||
viewport.zoom,
|
||||
]);
|
||||
|
||||
const handleDisableCurrentShare = useCallback(async () => {
|
||||
const handleDisableCurrentShare = useCallback(async () => {
|
||||
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
|
||||
toast.error("当前没有可关闭分享的世界资产");
|
||||
return;
|
||||
@ -2801,7 +2755,6 @@ export default function CanvasModel3DPage() {
|
||||
const handleSelectTask = (task: TaskRecord) => {
|
||||
setWorkspaceMode(inferWorkspaceModeFromTask(task));
|
||||
setCurrentTask(task);
|
||||
setSelectedWorldShareTaskIds([]);
|
||||
if (task.status === "success" && task.modelUrl) {
|
||||
setViewerLayers([]);
|
||||
setLayerVisibility({});
|
||||
@ -2886,7 +2839,17 @@ export default function CanvasModel3DPage() {
|
||||
currentWorldAsset?.thumbnailUrl ||
|
||||
(workspaceMode === "world" ? currentPreviewImage : null) ||
|
||||
null;
|
||||
const importDisabled = !getTaskImportContext(currentTask).canImport;
|
||||
const importDisabled =
|
||||
!currentTask ||
|
||||
currentTask.status !== "success" ||
|
||||
(currentTask.provider === "worldlabs-3d"
|
||||
? !Boolean(
|
||||
currentModelUrl ||
|
||||
currentWorldAsset?.worldUrl ||
|
||||
currentWorldImageUrl ||
|
||||
currentPrimarySpzUrl
|
||||
)
|
||||
: !currentModelUrl);
|
||||
const availableWorldViewportModes = useMemo(() => {
|
||||
const modes: WorldViewportMode[] = [];
|
||||
if (currentPrimarySpzUrl) modes.push("splat");
|
||||
@ -3007,7 +2970,7 @@ export default function CanvasModel3DPage() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => router.push(returnTo)}
|
||||
onClick={() => router.push(`/canvas/${id}`)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
@ -3675,7 +3638,7 @@ export default function CanvasModel3DPage() {
|
||||
<Button
|
||||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||||
disabled={importDisabled}
|
||||
onClick={() => void handleImportToCanvas(currentTask)}
|
||||
onClick={handleImportToCanvas}
|
||||
variant="secondary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||||
@ -3791,7 +3754,7 @@ export default function CanvasModel3DPage() {
|
||||
<Button
|
||||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||||
disabled={importDisabled}
|
||||
onClick={() => void handleImportToCanvas(currentTask)}
|
||||
onClick={handleImportToCanvas}
|
||||
variant="secondary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||||
@ -4285,33 +4248,33 @@ 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"
|
||||
>
|
||||
分享所选
|
||||
{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>
|
||||
@ -4346,89 +4309,36 @@ export default function CanvasModel3DPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollArea className="flex-1">
|
||||
<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 &&
|
||||
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}
|
||||
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(
|
||||
@ -4460,19 +4370,35 @@ export default function CanvasModel3DPage() {
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
) : null}
|
||||
{isSelectedForShare ? "已选" : "选择"}
|
||||
</Button>
|
||||
) : null}
|
||||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||||
<Button
|
||||
className="absolute right-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"
|
||||
onClick={(event) => handleDownloadTask(task, event)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</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"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
{isSelectedForAdd ? "已选" : "加画布"}
|
||||
</Button>
|
||||
) : null}
|
||||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||||
<Button
|
||||
className="absolute right-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"
|
||||
onClick={(event) => handleDownloadTask(task, event)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{task.previewImageUrl ? (
|
||||
<img
|
||||
alt="preview"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user