git-subtree-dir: services/pascal-editor git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
'use client'
|
||
|
||
// Node registry bootstrap is loaded once at the root via
|
||
// `<ClientBootstrap>` in `app/layout.tsx` — no per-page side-effect
|
||
// import here.
|
||
import {
|
||
applySceneGraphToEditor,
|
||
Editor,
|
||
type SceneGraph,
|
||
type SidebarTab,
|
||
} from '@pascal-app/editor'
|
||
import { Bot, Hammer, Layers } from 'lucide-react'
|
||
import Image from 'next/image'
|
||
import Link from 'next/link'
|
||
import { useRouter } from 'next/navigation'
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { withPascalBasePath } from '@/lib/pascal-base-path'
|
||
import { AiDesignAgentPanel } from './ai-design-agent-panel'
|
||
import { BuildTab } from './build-tab'
|
||
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
|
||
|
||
export interface SceneMeta {
|
||
id: string
|
||
name: string
|
||
projectId: string | null
|
||
thumbnailUrl: string | null
|
||
version: number
|
||
createdAt: string
|
||
updatedAt: string
|
||
ownerId: string | null
|
||
sizeBytes: number
|
||
nodeCount: number
|
||
}
|
||
|
||
const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
||
{
|
||
id: 'site',
|
||
label: '场景',
|
||
component: () => null, // Built-in SitePanel handles this
|
||
mobileDefaultSnap: 0.5,
|
||
mobileIcon: <Layers className="h-5 w-5" />,
|
||
icon: (
|
||
<Image
|
||
alt=""
|
||
className="h-8 w-8 object-contain"
|
||
height={32}
|
||
src={withPascalBasePath('/icons/scene.webp')}
|
||
width={32}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: 'build',
|
||
label: '搭建',
|
||
component: BuildTab,
|
||
mobileDefaultSnap: 0.5,
|
||
mobileIcon: <Hammer className="h-5 w-5" />,
|
||
icon: (
|
||
<Image
|
||
alt=""
|
||
className="h-8 w-8 object-contain"
|
||
height={32}
|
||
src={withPascalBasePath('/icons/build.webp')}
|
||
width={32}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
|
||
interface SceneLoaderProps {
|
||
initialScene: SceneGraph
|
||
meta: SceneMeta
|
||
}
|
||
|
||
type SceneGraphWithCollections = SceneGraph & {
|
||
collections?: Record<string, unknown>
|
||
}
|
||
|
||
interface LiveSceneEvent {
|
||
eventId: number
|
||
sceneId: string
|
||
version: number
|
||
kind: string
|
||
createdAt: string
|
||
graph: SceneGraphWithCollections
|
||
}
|
||
|
||
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
|
||
return JSON.stringify({
|
||
nodes: graph.nodes,
|
||
rootNodeIds: graph.rootNodeIds,
|
||
collections: graph.collections,
|
||
})
|
||
}
|
||
|
||
export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||
const router = useRouter()
|
||
const versionRef = useRef(meta.version)
|
||
const lastRemoteGraphJsonRef = useRef<string | null>(null)
|
||
const suppressRemoteSaveUntilRef = useRef(0)
|
||
const [conflict, setConflict] = useState(false)
|
||
const [saveError, setSaveError] = useState<string | null>(null)
|
||
const sidebarTabs = useMemo<(SidebarTab & { component: React.ComponentType })[]>(() => {
|
||
function SceneAgentPanel() {
|
||
return <AiDesignAgentPanel sceneId={meta.id} />
|
||
}
|
||
|
||
return [
|
||
...BASE_SIDEBAR_TABS,
|
||
{
|
||
id: 'ai',
|
||
label: 'AI 设计助手',
|
||
component: SceneAgentPanel,
|
||
mobileDefaultSnap: 0.72,
|
||
mobileIcon: <Bot className="h-5 w-5" />,
|
||
icon: <Bot className="h-6 w-6" />,
|
||
},
|
||
]
|
||
}, [meta.id])
|
||
|
||
const handleLoad = useCallback(async () => initialScene, [initialScene])
|
||
|
||
const handleSave = useCallback(
|
||
async (graph: SceneGraph, options?: { keepalive?: boolean }) => {
|
||
const graphJson = sceneGraphSignature(graph)
|
||
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
|
||
if (lastRemoteGraphJsonRef.current === graphJson) {
|
||
lastRemoteGraphJsonRef.current = null
|
||
suppressRemoteSaveUntilRef.current = 0
|
||
return
|
||
}
|
||
if (isRecentRemoteApply) return
|
||
|
||
try {
|
||
const response = await fetch(withPascalBasePath(`/api/scenes/${meta.id}`), {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'If-Match': String(versionRef.current),
|
||
},
|
||
body: JSON.stringify({ name: meta.name, graph }),
|
||
// `keepalive` lets the request outlive a page unload (the autosave
|
||
// flush on refresh/close). Browsers cap keepalive bodies at 64KB, so
|
||
// only the unload flush opts in — normal debounced saves omit it and
|
||
// can carry arbitrarily large scenes.
|
||
keepalive: options?.keepalive,
|
||
})
|
||
|
||
if (response.status === 409) {
|
||
setConflict(true)
|
||
return
|
||
}
|
||
|
||
if (!response.ok) {
|
||
setSaveError(`保存失败 (${response.status})`)
|
||
return
|
||
}
|
||
|
||
const next = (await response.json()) as SceneMeta
|
||
versionRef.current = next.version
|
||
setSaveError(null)
|
||
} catch (error) {
|
||
setSaveError(error instanceof Error ? error.message : '保存失败')
|
||
}
|
||
},
|
||
[meta.id, meta.name],
|
||
)
|
||
|
||
useEffect(() => {
|
||
const source = new EventSource(withPascalBasePath(`/api/scenes/${meta.id}/events`))
|
||
|
||
source.addEventListener('scene', (event) => {
|
||
let payload: LiveSceneEvent
|
||
try {
|
||
payload = JSON.parse((event as MessageEvent<string>).data) as LiveSceneEvent
|
||
} catch {
|
||
return
|
||
}
|
||
if (payload.sceneId !== meta.id) return
|
||
if (payload.version <= versionRef.current) return
|
||
|
||
versionRef.current = payload.version
|
||
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
|
||
suppressRemoteSaveUntilRef.current = Date.now() + 2500
|
||
applySceneGraphToEditor(payload.graph)
|
||
setConflict(false)
|
||
setSaveError(null)
|
||
})
|
||
|
||
source.addEventListener('error', () => {
|
||
if (source.readyState === EventSource.CLOSED) {
|
||
setSaveError('场景实时连接已断开')
|
||
}
|
||
})
|
||
|
||
return () => source.close()
|
||
}, [meta.id])
|
||
|
||
const handleThumb = useCallback(
|
||
async (_blob: Blob) => {
|
||
// TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail.
|
||
// Stub endpoint is not yet implemented in v0.1 — skip upload for now.
|
||
await fetch(withPascalBasePath(`/api/scenes/${meta.id}/thumbnail`), {
|
||
method: 'POST',
|
||
// Intentionally no body — endpoint is a stub.
|
||
}).catch(() => {
|
||
// Swallow errors silently; thumbnail upload is best-effort.
|
||
})
|
||
},
|
||
[meta.id],
|
||
)
|
||
|
||
return (
|
||
<div className="relative h-screen w-screen">
|
||
{conflict && (
|
||
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-xl">
|
||
<h2 className="font-semibold text-sm">其他会话已先保存,是否刷新?</h2>
|
||
<p className="mt-1 text-muted-foreground text-xs">
|
||
当前更改尚未保存,请重新加载最新版本。
|
||
</p>
|
||
<div className="mt-3 flex items-center gap-2">
|
||
<button
|
||
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-xs hover:bg-accent/80"
|
||
onClick={() => router.refresh()}
|
||
type="button"
|
||
>
|
||
重新加载
|
||
</button>
|
||
<button
|
||
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40"
|
||
onClick={() => setConflict(false)}
|
||
type="button"
|
||
>
|
||
暂不处理
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{saveError && !conflict && (
|
||
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-destructive/50 bg-background p-3 shadow-xl">
|
||
<p className="font-medium text-destructive text-xs">{saveError}</p>
|
||
</div>
|
||
)}
|
||
<div className="pointer-events-none absolute top-4 right-4 z-40 flex items-center gap-2">
|
||
<Link
|
||
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
|
||
href="/scenes"
|
||
>
|
||
全部场景
|
||
</Link>
|
||
</div>
|
||
<Editor
|
||
layoutVersion="v2"
|
||
onLoad={handleLoad}
|
||
onSave={handleSave}
|
||
onThumbnailCapture={handleThumb}
|
||
projectId={meta.projectId ?? 'default'}
|
||
sidebarTabs={sidebarTabs}
|
||
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
|
||
viewerToolbarRight={<CommunityViewerToolbarRight />}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|