/** * 多工程目录(project catalog) * * 单工程时代,整个控制台只有一个场景槽位,写在 localStorage 的基础 key 上。 * 支持多工程后,把「一个工程 = 一个命名存储槽」这个已有机制复用起来: * 每个工程分配一个稳定 id,其状态写在 `${DIRECTOR_SCENE_STORAGE_KEY}:${projectId}` 上, * 目录本身(有哪些工程、当前打开的是哪个)写在独立的一份 JSON 里。 * * 这样切工程 = 换存储槽 + 换目录里的 activeProjectId,不改动既有的 * readPersistedDirectorState / writePersistedDirectorState 读写路径。 * * 首次升级(没有目录、但基础 key 上有旧场景)会把旧场景搬迁成第一个工程,保证老用户不丢数据。 */ export const DIRECTOR_SCENE_STORAGE_KEY = "storyai-3d-director-desk-demo"; export const DIRECTOR_SCENE_STORAGE_KEY_PREFIX = `${DIRECTOR_SCENE_STORAGE_KEY}:`; export const PROJECT_CATALOG_STORAGE_KEY = "storyai-3d-director-project-catalog"; export const DEFAULT_PROJECT_NAME = "默认工程"; export const UNTITLED_PROJECT_NAME = "未命名工程"; export interface DirectorProjectMeta { id: string; name: string; createdAt: number; updatedAt: number; } export interface DirectorProjectCatalog { version: 1; activeProjectId: string; projects: DirectorProjectMeta[]; } function getStorage(): Storage | null { if (typeof localStorage === "undefined") return null; return localStorage; } /** 归一化工程 / 宿主实例的作用域 id:非字符串一律视为「没有作用域」(落回基础 key) */ export function normalizeProjectScopeId(scopeId: string | null | undefined) { return typeof scopeId === "string" ? scopeId.trim() : ""; } /** 由作用域 id 得到该工程在 localStorage 里的场景 key;没有作用域时退回基础 key */ export function getScopedSceneStorageKey(scopeId: string | null | undefined) { const normalizedScopeId = normalizeProjectScopeId(scopeId); return normalizedScopeId ? `${DIRECTOR_SCENE_STORAGE_KEY_PREFIX}${normalizedScopeId}` : DIRECTOR_SCENE_STORAGE_KEY; } export function generateProjectId(): string { return `proj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } /** 「未命名工程」「未命名工程 2」「未命名工程 3」…… 自动避开重名 */ export function createUntitledProjectName(existing: DirectorProjectMeta[]): string { const used = new Set(existing.map((project) => project.name)); if (!used.has(UNTITLED_PROJECT_NAME)) return UNTITLED_PROJECT_NAME; let index = 2; while (used.has(`${UNTITLED_PROJECT_NAME} ${index}`)) { index += 1; } return `${UNTITLED_PROJECT_NAME} ${index}`; } export function createProjectMeta( existing: DirectorProjectMeta[], name?: string | null, now = Date.now(), id: string = generateProjectId() ): DirectorProjectMeta { const trimmedName = typeof name === "string" ? name.trim() : ""; return { id, name: trimmedName || createUntitledProjectName(existing), createdAt: now, updatedAt: now, }; } function isProjectMeta(value: unknown): value is DirectorProjectMeta { if (!value || typeof value !== "object") return false; const meta = value as Partial; return ( typeof meta.id === "string" && meta.id.length > 0 && typeof meta.name === "string" && typeof meta.createdAt === "number" && typeof meta.updatedAt === "number" ); } /** * 读取工程目录;不存在 / 损坏 / 空目录一律返回 null,由 ensureProjectCatalog 负责重建。 * activeProjectId 不在列表里时回落到第一个工程,避免目录自相矛盾。 */ export function readProjectCatalog(): DirectorProjectCatalog | null { const storage = getStorage(); if (!storage) return null; try { const raw = storage.getItem(PROJECT_CATALOG_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial | null; if (!parsed || typeof parsed !== "object") return null; const projects = Array.isArray(parsed.projects) ? parsed.projects.filter(isProjectMeta) : []; if (projects.length === 0) return null; const activeProjectId = typeof parsed.activeProjectId === "string" && projects.some((project) => project.id === parsed.activeProjectId) ? parsed.activeProjectId : projects[0].id; return { version: 1, activeProjectId, projects }; } catch { return null; } } export function writeProjectCatalog(catalog: DirectorProjectCatalog): void { const storage = getStorage(); if (!storage) return; try { storage.setItem(PROJECT_CATALOG_STORAGE_KEY, JSON.stringify(catalog)); } catch { // 目录很小,几乎不会写失败;真失败也不该让编辑器崩掉。 } } /** * 保证目录存在:不存在就建一个,并把基础 key 上的旧单工程场景搬迁为第一个工程。 * 只在「完全没有目录」时创建,已有目录原样返回,不覆盖用户数据。 */ export function ensureProjectCatalog(now = Date.now()): DirectorProjectCatalog { const existing = readProjectCatalog(); if (existing) return existing; const storage = getStorage(); const project = createProjectMeta([], DEFAULT_PROJECT_NAME, now); // 旧数据搬迁:单工程时代写在基础 key 上,这里复制(不删除)到新工程的槽位。 if (storage) { try { const legacy = storage.getItem(DIRECTOR_SCENE_STORAGE_KEY); if (legacy) { storage.setItem(getScopedSceneStorageKey(project.id), legacy); } } catch { // 搬迁失败不影响目录创建:新工程从默认场景开始。 } } const catalog: DirectorProjectCatalog = { version: 1, activeProjectId: project.id, projects: [project], }; writeProjectCatalog(catalog); return catalog; } /** 更新某个工程的 updatedAt(每次落盘时调用),工程不存在则静默忽略 */ export function touchProjectUpdatedAt(projectId: string | null | undefined, now = Date.now()): void { const normalizedId = normalizeProjectScopeId(projectId); if (!normalizedId) return; const catalog = readProjectCatalog(); if (!catalog) return; const index = catalog.projects.findIndex((project) => project.id === normalizedId); if (index < 0) return; const projects = catalog.projects.map((project, projectIndex) => projectIndex === index ? { ...project, updatedAt: now } : project ); writeProjectCatalog({ ...catalog, projects }); } /** 读取某个工程槽位里的原始场景 JSON 字符串(未做迁移 / 校验) */ export function readScopedSceneRaw(scopeId: string | null | undefined): string | null { const storage = getStorage(); if (!storage) return null; try { return storage.getItem(getScopedSceneStorageKey(scopeId)); } catch { return null; } } /** 直接写入某个工程槽位的原始场景 JSON(用于复制 / 导入工程文件) */ export function writeScopedSceneRaw(scopeId: string | null | undefined, raw: string): void { const storage = getStorage(); if (!storage) return; try { storage.setItem(getScopedSceneStorageKey(scopeId), raw); } catch { // 场景可能含 base64 全景图,超配额时静默失败,保持编辑器可用。 } } export function removeScopedScene(scopeId: string | null | undefined): void { const storage = getStorage(); if (!storage) return; try { storage.removeItem(getScopedSceneStorageKey(scopeId)); } catch { // 忽略:删除失败最多留一份孤儿数据。 } } /** * 启动时决定应该打开哪个工程槽位: * - 宿主通过 `?instanceId=` 指定了实例作用域时,优先用宿主作用域(嵌入场景不抢多工程的槽位); * - 否则打开目录里当前激活的工程(首次运行会先建目录并搬迁旧场景)。 */ export function resolveStartupProjectScopeId(hostScopeId: string | null | undefined): string | null { const normalizedHostId = normalizeProjectScopeId(hostScopeId); if (normalizedHostId) return normalizedHostId; try { return ensureProjectCatalog().activeProjectId; } catch { return null; } }