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:
Your Name 2026-06-08 16:56:57 +08:00
parent c9fe549e86
commit afd1f84789

View File

@ -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"