diff --git a/app/api/canvas/[id]/model3d-assets/route.ts b/app/api/canvas/[id]/model3d-assets/route.ts index efa9c7aef..9b0102a90 100644 --- a/app/api/canvas/[id]/model3d-assets/route.ts +++ b/app/api/canvas/[id]/model3d-assets/route.ts @@ -1,4 +1,5 @@ import { and, desc, eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; @@ -100,6 +101,10 @@ function readProgress(input: unknown): number | null { return Math.max(0, Math.min(100, Math.round(value))); } +function createShareToken() { + return randomUUID().replace(/-/g, ""); +} + function readDate(input: unknown): Date | null { if (!input) return null; if (input instanceof Date && !Number.isNaN(input.getTime())) return input; @@ -290,3 +295,89 @@ export async function DELETE( return NextResponse.json({ success: false, error: "清理 3D 资产失败" }, { status: 500 }); } } + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const { id: projectId } = await params; + const ownsProject = await ensureProjectOwnership(projectId, session.user.id); + if (!ownsProject) { + return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 }); + } + + const body = await request.json().catch(() => ({})); + const taskId = String((body as any)?.taskId || "").trim(); + if (!taskId) { + return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); + } + + const [existingAsset] = await db + .select({ + id: model3dAsset.id, + provider: model3dAsset.provider, + status: model3dAsset.status, + shareEnabled: model3dAsset.shareEnabled, + shareToken: model3dAsset.shareToken, + }) + .from(model3dAsset) + .where( + and( + eq(model3dAsset.projectId, projectId), + eq(model3dAsset.userId, session.user.id), + eq(model3dAsset.taskId, taskId) + ) + ) + .limit(1); + + if (!existingAsset) { + return NextResponse.json({ success: false, error: "资产不存在" }, { status: 404 }); + } + + if (existingAsset.provider !== "worldlabs-3d") { + return NextResponse.json( + { success: false, error: "当前仅支持分享 3D 世界资产" }, + { status: 400 } + ); + } + + if (existingAsset.status !== "success") { + return NextResponse.json( + { success: false, error: "仅成功生成的 3D 世界支持分享" }, + { status: 400 } + ); + } + + const shouldEnable = (body as any)?.shareEnabled !== false; + const nextValues = shouldEnable + ? { + shareEnabled: true, + shareToken: existingAsset.shareToken || createShareToken(), + sharedAt: new Date(), + updatedAt: new Date(), + } + : { + shareEnabled: false, + shareToken: null, + sharedAt: null, + updatedAt: new Date(), + }; + + const [updatedAsset] = await db + .update(model3dAsset) + .set(nextValues) + .where(eq(model3dAsset.id, existingAsset.id)) + .returning(); + + return NextResponse.json({ success: true, data: updatedAsset }); + } catch (error) { + console.error("[Model3DAssets] PATCH failed:", error); + return NextResponse.json({ success: false, error: "更新 3D 世界分享状态失败" }, { status: 500 }); + } +} diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 8a2cae585..7355c1ce7 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -109,6 +109,34 @@ async function archiveRemoteAssetIfNeeded(params: { return archivedUrl || sourceUrl; } +async function archiveRemoteAssetMapIfNeeded(params: { + cacheKeyPrefix: string; + sourceMap?: Record | null; + filenamePrefix: string; + fallbackExtension: string; +}) { + const entries = Object.entries(params.sourceMap || {}).filter( + (entry): entry is [string, string] => + Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0 + ); + + if (entries.length === 0) return null; + + const archivedEntries = await Promise.all( + entries.map(async ([key, value]) => { + const archivedUrl = await archiveRemoteAssetIfNeeded({ + cacheKey: `${params.cacheKeyPrefix}:${key}`, + sourceUrl: value, + filenamePrefix: `${params.filenamePrefix}-${key}`, + fallbackExtension: params.fallbackExtension, + }); + return [key, archivedUrl || value] as const; + }) + ); + + return Object.fromEntries(archivedEntries); +} + function getStringValue(...values: unknown[]) { for (const value of values) { if (typeof value === "string" && value.trim()) { @@ -253,6 +281,16 @@ export async function GET(_request: Request, { params }: RouteParams) { fallbackExtension: "jpg", }); + const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({ + cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`, + sourceMap: + assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object" + ? assets.splats.spz_urls + : null, + filenamePrefix: `worldlabs-splat-${normalizedTaskId}`, + fallbackExtension: "spz", + }); + const assetPayload = buildAssetPayload(world, assets); return NextResponse.json({ @@ -274,6 +312,7 @@ export async function GET(_request: Request, { params }: RouteParams) { thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, panoUrl: stablePanoUrl || assetPayload.panoUrl, meshUrl: stableModelUrl || assetPayload.meshUrl, + spzUrls: stableSpzUrls || assetPayload.spzUrls, }, }, }); diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 476d975fd..4861e68bf 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -42,7 +42,7 @@ import { } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { @@ -134,6 +134,9 @@ type TaskRecord = { sourceTaskId?: string | null; exportFormat?: string | null; assetPayload?: WorldLabsTaskAssetPayload | null; + shareEnabled?: boolean; + shareToken?: string | null; + sharedAt?: number | null; }; type TaskRecordApiEntity = { @@ -150,6 +153,9 @@ type TaskRecordApiEntity = { sourceTaskId?: unknown; exportFormat?: unknown; assetPayload?: unknown; + shareEnabled?: unknown; + shareToken?: unknown; + sharedAt?: unknown; createdAt?: unknown; }; @@ -289,6 +295,13 @@ function extractModelFormat(modelUrl?: string | null): string { return "3d"; } +function resolveTaskModelUrl(task?: TaskRecord | null): string | null { + if (!task) return null; + if (task.modelUrl) return task.modelUrl; + const meshUrl = task.assetPayload?.meshUrl; + return typeof meshUrl === "string" && meshUrl.trim() ? meshUrl.trim() : null; +} + function createMultiViewSlots(count = 4): MultiViewSlot[] { return Array.from({ length: count }, (_, index) => ({ id: `slot_${index + 1}`, @@ -382,6 +395,15 @@ function makeStorageKey(projectId: string) { return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`; } +function buildWorldShareUrl(shareToken?: string | null) { + const normalized = String(shareToken || "").trim(); + if (!normalized) return null; + if (typeof window === "undefined") { + return `/worlds/share/${normalized}`; + } + return new URL(`/worlds/share/${normalized}`, window.location.origin).toString(); +} + function normalizeTaskProvider(provider: unknown): TaskProvider { return String(provider || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d"; } @@ -409,6 +431,11 @@ function toTaskTimestamp(value: unknown): number { return Date.now(); } +function toNullableTaskTimestamp(value: unknown): number | null { + if (value === undefined || value === null || value === "") return null; + return toTaskTimestamp(value); +} + function normalizeTaskRecord(raw: TaskRecord): TaskRecord { return { ...raw, @@ -433,6 +460,9 @@ function normalizeTaskRecord(raw: TaskRecord): TaskRecord { sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null, exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null, assetPayload: normalizeAssetPayload(raw.assetPayload), + shareEnabled: raw.shareEnabled === true, + shareToken: raw.shareToken ? String(raw.shareToken).trim() : null, + sharedAt: toNullableTaskTimestamp(raw.sharedAt), }; } @@ -460,6 +490,9 @@ function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | nul sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null, exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null, assetPayload: normalizeAssetPayload(entity?.assetPayload), + shareEnabled: entity?.shareEnabled === true, + shareToken: entity?.shareToken ? String(entity.shareToken).trim() : null, + sharedAt: toNullableTaskTimestamp(entity?.sharedAt), }); } @@ -914,6 +947,9 @@ export default function CanvasModel3DPage() { } return [normalized, ...prev].slice(0, TASK_HISTORY_LIMIT); }); + setCurrentTask((prev) => + prev?.taskId === normalized.taskId ? { ...prev, ...normalized } : prev + ); if (options?.persist !== false) { void persistTaskRecord(normalized); @@ -1662,18 +1698,111 @@ export default function CanvasModel3DPage() { }; const handleDownloadCurrent = () => { - if (!currentModelUrl) { + const modelUrl = resolveTaskModelUrl(currentTask); + if (!modelUrl) { toast.error("暂无可下载的 3D 模型"); return; } const link = document.createElement("a"); - link.href = currentModelUrl; + link.href = modelUrl; link.download = `${ currentTask?.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model" - }-${currentTask?.taskId || "result"}.${extractModelFormat(currentModelUrl)}`; + }-${currentTask?.taskId || "result"}.${extractModelFormat(modelUrl)}`; link.click(); }; + const handleDownloadTask = ( + task: TaskRecord, + event?: MouseEvent + ) => { + event?.stopPropagation(); + const modelUrl = resolveTaskModelUrl(task); + if (!modelUrl) { + toast.error(task.provider === "worldlabs-3d" ? "该世界暂无可下载的 Mesh" : "该模型暂无可下载文件"); + return; + } + const link = document.createElement("a"); + link.href = modelUrl; + link.download = `${ + task.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model" + }-${task.taskId}.${extractModelFormat(modelUrl)}`; + link.click(); + }; + + const updateWorldShareState = useCallback( + async (task: TaskRecord, shareEnabled: boolean) => { + const response = await fetch(`/api/canvas/${id}/model3d-assets`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskId: task.taskId, + shareEnabled, + }), + }); + const payload = (await response.json().catch(() => ({}))) as { + success?: boolean; + error?: string; + data?: TaskRecordApiEntity; + }; + if (!response.ok || payload?.success === false) { + throw new Error(payload?.error || "更新世界分享状态失败"); + } + const updatedTask = payload?.data ? mapApiEntityToTaskRecord(payload.data) : null; + if (!updatedTask) { + throw new Error("分享结果返回无效"); + } + upsertTaskHistory(updatedTask, { persist: false }); + return updatedTask; + }, + [id, upsertTaskHistory] + ); + + const handleShareTask = useCallback( + async (task?: TaskRecord | null, event?: MouseEvent) => { + event?.stopPropagation(); + if (!task || task.provider !== "worldlabs-3d" || task.status !== "success") { + toast.error("仅成功生成的 3D 世界支持分享"); + return; + } + + try { + const sharedTask = + task.shareEnabled && task.shareToken + ? task + : await updateWorldShareState(task, true); + const shareUrl = buildWorldShareUrl(sharedTask.shareToken); + if (!shareUrl) { + throw new Error("分享链接生成失败"); + } + await navigator.clipboard.writeText(shareUrl); + toast.success( + task.shareEnabled ? "已复制世界分享链接" : "已开启分享并复制链接" + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "分享世界失败"); + } + }, + [updateWorldShareState] + ); + + const handleDisableCurrentShare = useCallback(async () => { + if (!currentTask || currentTask.provider !== "worldlabs-3d") { + toast.error("当前没有可关闭分享的世界资产"); + return; + } + if (!currentTask.shareEnabled) { + toast.error("当前世界尚未开启分享"); + return; + } + + try { + await updateWorldShareState(currentTask, false); + toast.success("已关闭世界分享"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "关闭分享失败"); + } + }, [currentTask, updateWorldShareState]); + const handleOpenWorldUrl = () => { const worldUrl = currentTask?.assetPayload?.worldUrl; if (!worldUrl) { @@ -1986,6 +2115,10 @@ export default function CanvasModel3DPage() { currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null; const currentWorldId = currentWorldAsset?.worldId || currentTask?.sourceTaskId || null; + const currentWorldShareUrl = + currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled + ? buildWorldShareUrl(currentTask.shareToken) + : null; const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {}); const currentPrimarySpzUrl = currentSpzEntries.find(([, url]) => typeof url === "string" && url.trim())?.[1] || null; @@ -2908,6 +3041,26 @@ export default function CanvasModel3DPage() { +
+ + +
+ 当前世界资产 @@ -2979,7 +3132,25 @@ export default function CanvasModel3DPage() { {currentModelUrl ? "可导入" : "缺失"} +
+ + + 分享页 + + + {currentWorldShareUrl ? "已开启" : "未开启"} + +
+ {currentWorldShareUrl ? ( +
+
分享链接
+
{currentWorldShareUrl}
+
+ ) : null}
@@ -3112,7 +3283,19 @@ export default function CanvasModel3DPage() { {currentModelUrl ? "已归档" : "暂无"} +
+
分享页
+
+ {currentWorldShareUrl ? "已开启" : "未开启"} +
+
+ {currentWorldShareUrl ? ( +
+
分享链接
+
{currentWorldShareUrl}
+
+ ) : null}

SPZ 列表 @@ -3346,7 +3529,34 @@ export default function CanvasModel3DPage() { )} key={task.taskId} onClick={() => handleSelectTask(task)} + type="button" > + {task.provider === "worldlabs-3d" && task.status === "success" ? ( + + ) : null} + {task.status === "success" && resolveTaskModelUrl(task) ? ( + + ) : null} {task.previewImageUrl ? ( preview + {task.status === "success" && resolveTaskModelUrl(task) ? ( +
+ 点击右上角可下载模型 +
+ ) : null} + {task.provider === "worldlabs-3d" && task.shareEnabled ? ( +
+ 已开启分享 +
+ ) : null}

))} diff --git a/app/worlds/share/[token]/page.tsx b/app/worlds/share/[token]/page.tsx new file mode 100644 index 000000000..6de958529 --- /dev/null +++ b/app/worlds/share/[token]/page.tsx @@ -0,0 +1,100 @@ +import { and, eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { WorldSharePage } from "@/components/canvas/world3d/WorldSharePage"; +import { db } from "@/lib/db"; +import { model3dAsset } from "@/lib/db/schema"; + +export const dynamic = "force-dynamic"; + +function readAssetPayload( + value: unknown +): { + displayName?: string | null; + model?: string | null; + caption?: string | null; + worldUrl?: string | null; + thumbnailUrl?: string | null; + panoUrl?: string | null; + meshUrl?: string | null; + spzUrls?: Record | null; + semanticsMetadata?: Record | null; +} | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as { + displayName?: string | null; + model?: string | null; + caption?: string | null; + worldUrl?: string | null; + thumbnailUrl?: string | null; + panoUrl?: string | null; + meshUrl?: string | null; + spzUrls?: Record | null; + semanticsMetadata?: Record | null; + }) + : null; +} + +function pickPrimarySplatUrl(assetPayload: ReturnType) { + if (!assetPayload?.spzUrls || typeof assetPayload.spzUrls !== "object") return null; + for (const value of Object.values(assetPayload.spzUrls)) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; +} + +export default async function SharedWorldPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + const shareToken = String(token || "").trim(); + if (!shareToken) { + notFound(); + } + + const [asset] = await db + .select({ + taskId: model3dAsset.taskId, + prompt: model3dAsset.prompt, + modelVersion: model3dAsset.modelVersion, + previewImageUrl: model3dAsset.previewImageUrl, + modelUrl: model3dAsset.modelUrl, + assetPayload: model3dAsset.assetPayload, + }) + .from(model3dAsset) + .where( + and( + eq(model3dAsset.shareEnabled, true), + eq(model3dAsset.shareToken, shareToken), + eq(model3dAsset.provider, "worldlabs-3d"), + eq(model3dAsset.status, "success") + ) + ) + .limit(1); + + if (!asset) { + notFound(); + } + + const assetPayload = readAssetPayload(asset.assetPayload); + const sharePath = `/worlds/share/${shareToken}`; + + return ( + + ); +} diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 60def880a..5c0327dc5 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -98,6 +98,15 @@ const KEY_TO_MOVEMENT: Partial> = { PageDown: "down", }; +function isEditableKeyboardTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + + const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']"; + if (target.matches(editableSelector)) return true; + return Boolean(target.closest(editableSelector)); +} + function getSemanticFacts(metadata?: Record | null) { if (!metadata) return []; @@ -261,20 +270,36 @@ function CameraMovementControls({ const handleKeyDown = (event: KeyboardEvent) => { const movement = KEY_TO_MOVEMENT[event.code]; if (!movement) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isEditableKeyboardTarget(event.target)) { + pressedKeysRef.current = createMovementState(); + return; + } pressedKeysRef.current[movement] = true; }; const handleKeyUp = (event: KeyboardEvent) => { const movement = KEY_TO_MOVEMENT[event.code]; if (!movement) return; + if (isEditableKeyboardTarget(event.target)) { + pressedKeysRef.current = createMovementState(); + return; + } pressedKeysRef.current[movement] = false; }; + const handleFocusIn = (event: FocusEvent) => { + if (isEditableKeyboardTarget(event.target)) { + pressedKeysRef.current = createMovementState(); + } + }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); + window.addEventListener("focusin", handleFocusIn); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("focusin", handleFocusIn); }; }, [enabled]); diff --git a/components/canvas/world3d/WorldSharePage.tsx b/components/canvas/world3d/WorldSharePage.tsx new file mode 100644 index 000000000..35a92b020 --- /dev/null +++ b/components/canvas/world3d/WorldSharePage.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { + Box, + Download, + ExternalLink, + Globe, + ImageIcon, + Link2, + Sparkles, + Waypoints, +} from "lucide-react"; +import { 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 | null; + shareUrl: string; +}; + +const VIEW_OPTIONS: Array<{ + id: World3DViewMode; + label: string; + icon: typeof Sparkles; +}> = [ + { id: "splat", label: "Splat", icon: Sparkles }, + { id: "world", label: "World", icon: Globe }, + { 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.worldUrl) views.push("world"); + 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("world")) return "world"; + 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, +}: SharedWorldPageProps) { + const availableViews = useMemo( + () => resolveAvailableViews({ worldUrl, imageUrl, meshUrl, splatUrl }), + [imageUrl, meshUrl, splatUrl, worldUrl] + ); + const [activeView, setActiveView] = useState( + inferInitialView(availableViews) + ); + const [firstPerson, setFirstPerson] = useState(false); + const [copied, setCopied] = useState(false); + + const primaryDescription = caption || prompt || "这个 3D 世界已开启只读分享。"; + + 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 (!meshUrl) return; + const link = document.createElement("a"); + link.href = meshUrl; + link.download = "shared-world.glb"; + link.click(); + }; + + return ( +
+
+
+
+
+ + 3D 世界分享 + + {model ? ( + + {model} + + ) : null} + {availableViews.map((view) => ( + + {view.toUpperCase()} + + ))} +
+
+

+ {title} +

+

+ {primaryDescription} +

+
+
+ +
+ + {worldUrl ? ( + + ) : null} + {meshUrl ? ( + + ) : null} +
+
+ +
+
+
+
+ {VIEW_OPTIONS.filter((item) => availableViews.includes(item.id)).map((item) => ( + + ))} +
+ + {activeView === "splat" && splatUrl ? ( + + ) : null} +
+ +
+ +
+
+ +
+
+

查看说明

+
+

`Splat` 视图最接近原生 3D 世界体验,支持方向键或 `WASD` 六向移动。

+

`World` 视图会直接打开 World Labs 提供的公开页面。

+

当前页面是只读分享页,不会修改原项目或原画布内容。

+
+
+ +
+

资源状态

+
+
+
Splat
+
{splatUrl ? "可查看" : "暂无"}
+
+
+
World
+
{worldUrl ? "可打开" : "暂无"}
+
+
+
图像
+
{imageUrl || previewImageUrl ? "可查看" : "暂无"}
+
+
+
Mesh
+
{meshUrl ? "可下载" : "暂无"}
+
+
+
+
+
+
+
+ ); +} diff --git a/lib/db/migrations/0064_add_model3d_asset_share.sql b/lib/db/migrations/0064_add_model3d_asset_share.sql new file mode 100644 index 000000000..9ab440196 --- /dev/null +++ b/lib/db/migrations/0064_add_model3d_asset_share.sql @@ -0,0 +1,11 @@ +ALTER TABLE "Model3DAsset" + ADD COLUMN IF NOT EXISTS "shareEnabled" boolean NOT NULL DEFAULT false; + +ALTER TABLE "Model3DAsset" + ADD COLUMN IF NOT EXISTS "shareToken" varchar(64); + +ALTER TABLE "Model3DAsset" + ADD COLUMN IF NOT EXISTS "sharedAt" timestamp; + +CREATE UNIQUE INDEX IF NOT EXISTS "Model3DAsset_shareToken_idx" + ON "Model3DAsset" ("shareToken"); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 1111384fd..7beb669e5 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1775289600000, "tag": "0063_add_worldlabs_3d_assets", "breakpoints": true + }, + { + "idx": 50, + "version": "7", + "when": 1775342400000, + "tag": "0064_add_model3d_asset_share", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index a0cd04f3d..932b4a7e6 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -779,6 +779,9 @@ export const model3dAsset = pgTable("Model3DAsset", { sourceTaskId: varchar("sourceTaskId", { length: 128 }), exportFormat: varchar("exportFormat", { length: 32 }), assetPayload: jsonb("assetPayload").$type | null>(), + shareEnabled: boolean("shareEnabled").notNull().default(false), + shareToken: varchar("shareToken", { length: 64 }), + sharedAt: timestamp("sharedAt"), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), });