ai-chatbot/apps/editor/components/scene-loader.tsx
134 c3faeba1c8 Squashed 'services/pascal-editor/' content from commit 312064e6
git-subtree-dir: services/pascal-editor
git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
2026-07-12 00:43:59 +08:00

265 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}