241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
/**
|
||
* 多工程目录(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<DirectorProjectMeta>;
|
||
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<DirectorProjectCatalog> | 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;
|
||
}
|
||
}
|