docs(workspace): explain host page and sidebar lifecycles

This commit is contained in:
2026-11-12 01:46:09 +08:00
parent 86dac408c3
commit 1306df2b35
8 changed files with 115 additions and 0 deletions
+25
View File
@@ -1,3 +1,8 @@
/**
* 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板,
* 活动board元数据来自Context,画布业务状态由RuntimeHost拥有;这里不持有第二份元素文档。
* 浮动条在画布路由或侧栏折叠时显示,展开按钮仅在折叠时显示,收起按钮留在展开侧栏中。
*/
"use client";
import { useEffect, useRef, useState } from "react";
@@ -19,6 +24,7 @@ import { loadBoardDirectory } from "@/lib/board-directory";
import { BoardDirectory } from "@/components/workspace/board-directory";
import styles from "@/components/workspace/workspace.module.css";
/** 路径/查询参数优先定位空间,目录解析真实ID,UI状态与账户/目录异步生命周期分别维护。 */
export default function DashboardSidebar() {
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
@@ -42,13 +48,16 @@ export default function DashboardSidebar() {
const expandButton = useRef<HTMLButtonElement>(null);
const collapseButton = useRef<HTMLButtonElement>(null);
const hasToggled = useRef(false);
// 同步ref防重复点击创建,listVersion隔离迟到目录列表;二者都不是runtime账户authEpoch。
const createLock = useRef(false);
const listVersion = useRef(0);
const accountPopup = useRef<HTMLDivElement>(null);
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
useEffect(() => {
if (demo) {
setProfile({ id: "demo", username: "creator", display_name: "创作者", email: null });
@@ -77,6 +86,7 @@ export default function DashboardSidebar() {
return () => { active = false; controller.abort(); };
}, [demo, router]);
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
useEffect(() => {
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
@@ -93,13 +103,17 @@ export default function DashboardSidebar() {
return () => { active = false; controller.abort(); };
}, [workspace, demo, status, directoryRevision]);
/** 同工作区目录变化事件合并100ms后重读,清理时撤销计时和监听,不跨空间误刷新。 */
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
/** 忽略其它workspace事件;多次事件只触发一次revision递增和后续完整目录读取。 */
const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); };
window.addEventListener("endoleap:board-directory-changed", refresh);
return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); };
}, [workspace]);
/** 就绪且未重复创建才执行,真实模式必要时先建个人空间,demo只写本地目录。
* 成功递增listVersion防旧列表盖掉新项,再清搜索并导航;创建锁仅保护此UI操作,不替代账户身份护栏。 */
async function newPage() {
if (createLock.current || status !== "ready") return;
createLock.current = true;
@@ -121,6 +135,7 @@ export default function DashboardSidebar() {
finally { createLock.current = false; setCreating(false); }
}
/** 用户实际切换折叠后,下一帧聚焦新出现的展开/收起按钮;初始渲染不抢焦点。 */
useEffect(() => {
if (!hasToggled.current) return;
const frame = requestAnimationFrame(() => {
@@ -129,17 +144,20 @@ export default function DashboardSidebar() {
return () => cancelAnimationFrame(frame);
}, [collapsed]);
/** 先关闭账户popover再切布局,避免旧锚点残留弹层;焦点转移交给effect。 */
function toggleSidebar(value: boolean) {
accountPopup.current?.hidePopover();
hasToggled.current = true;
setCollapsed(value);
}
/** 关闭popover后打开设置/新空间对话框,避免两层交互同时争焦点。 */
function openPanel(mode: "settings" | "workspace") {
accountPopup.current?.hidePopover();
setPanel(mode);
}
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
async function logout() {
try {
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
@@ -147,13 +165,17 @@ export default function DashboardSidebar() {
} catch (e) { setError(errorText(e)); }
}
// 已解析活动board标题优先,退回目录标题或加载文案;不从另一工作区的活动记录借名。
const currentTitle = activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace
? activeBoard.title
: boards.find((board) => matchesRef(board, params.board))?.title || (params.board ? "正在读取图谱…" : "工作空间");
const name = profile?.display_name || profile?.username || "账户";
// 计数用标题直接匹配;树中保留的搜索祖先由BoardDirectory独立派生,因此数目不等于所有可见行数。
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
// UI能力只作提示/禁用,真实创建权限仍由服务端判定,未知空间不能当成已有编辑权限。
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
async function renameTitle(title: string) {
const id = activeBoard?.id || params.board;
if (!id || !workspace) return;
@@ -167,11 +189,14 @@ export default function DashboardSidebar() {
} catch (error) { throw new Error(errorText(error)); }
}
// 创建入口复用同一busy/权限状态,降低重复点击,但不在render中发请求。
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
// 尊重减少动态效果偏好;折叠侧栏同时inert/aria-hidden,避免不可见内容仍接受键盘交互。
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间";
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
return (
<>
<AnimatePresence initial={false}>
@@ -1,3 +1,7 @@
/**
* 账户资料编辑与新建工作区共用的客户端对话框。保存后通过回调更新宿主目录/资料,再关闭;
* demo分支只构造本地结果,真实分支走profile PATCH或workspaces POST,不由组件直接操作canvas runtime。
*/
"use client";
import { useState, type FormEvent } from "react";
@@ -7,6 +11,7 @@ import type { Workspace } from "@/lib/workspace";
import { UserAvatar } from "./user-avatar";
import styles from "./workspace.module.css";
/** mode决定表单,busy控制当前提交和退出,error用于就地反馈;输入默认值来自打开时profile。 */
export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorkspace }: {
mode: "settings" | "workspace";
profile: Profile;
@@ -20,6 +25,8 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
const settings = mode === "settings";
const name = profile.display_name || profile.username;
/** 阻止原生导航,捕获FormData后提交;成功先通知宿主再onClose,失败显示错误并恢复按钮。
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
@@ -27,6 +34,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
setBusy(true);
setError("");
try {
// 设置模式只提交display_name/username,避免将整个profile(含只读信息)作为修改payload。
if (settings) {
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
@@ -41,6 +49,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
finally { setBusy(false); }
}
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
return (
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
<DialogContent className={styles.settingsPanel}>
@@ -1,20 +1,28 @@
/**
* 侧栏与画布页面共享的当前board元数据Context。只保存ID、工作区、路由引用与标题,
* 不保存元素、加载任务或ProjectEngine;目录改名可更新标题而无需重建业务画布。
*/
"use client";
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { Board } from "@/lib/workspace";
/** null代表当前没有已解析画布,workspace_ref保留路径slug/引用与真实workspace_id的对应关系。 */
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
/** 暴露React状态setter,调用方可条件更新以免迟到改名覆盖另一个活动board。 */
const ActiveBoardContext = createContext<{
board: ActiveBoard;
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
} | null>(null);
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
const [board, setBoard] = useState<ActiveBoard>(null);
const value = useMemo(() => ({ board, setBoard }), [board]);
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
}
/** 缺少Provider时拒绝读取;不从浏览器路径凭空构造一条已认证board记录。 */
export function useActiveBoard() {
const context = useContext(ActiveBoardContext);
if (!context) throw new Error("useActiveBoard requires ActiveBoardProvider");
@@ -1,3 +1,7 @@
/**
* 侧栏画布树显示组件,使用业务目录库派生结构/搜索路径,只保存哪些目录项被用户折叠。
* 不重写board父子归属;活动路径和搜索可临时展开祖先,路由Link负责进入真实画布页面。
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
@@ -6,13 +10,16 @@ import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardB
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
const [closed, setClosed] = useState(new Set<string>());
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
useEffect(() => {
const path = activeBoardAncestors(boards, active);
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
}, [active, boards]);
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
const expanded = !!query.trim() || !closed.has(board.id);
@@ -22,5 +29,6 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
})}</ul>;
}
// 有结果显示树;仅搜索无命中给提示,原始目录空时不伪造一条默认页面。
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
}
@@ -1,3 +1,8 @@
/**
* 应用级Canvas运行时装配点。一次安装模型registry与React renderer目录,按账户/工作区创建runtime,
* 按项目root安装Foundry服务适配。组件只装配依赖,业务根、history、ledger和持久化仍由下层实例管理。
* 真实宿主卸载才关闭项目;单个画布页面切换只借还视图,不在这里每次重建engine。
*/
"use client";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
@@ -10,20 +15,29 @@ import { createRuntimeHost, createWorkspaceRuntime, type RuntimeHost } from "@/l
import { createBrowserDigests, createBrowserImageDecoder, createBrowserStorage } from "@/lib/canvas-runtime/browser";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns,createFoundryModelLoader } from "@/lib/canvas-service/index";
/** 应用共享RuntimeHost引用,未装配时为null;renderer Context另有内置显示目录默认值。 */
const Context = createContext<RuntimeHost | null>(null);
/** 模型定义与显示实现一起在宿主初始化捕获,子页面用此目录保持一致解释。 */
const Renderers=createContext(defaultCanvasRenderers);
/** 读取宿主安装的React renderer集合,不为每个节点重新构造类型表。 */
export function useCanvasRenderers(){return useContext(Renderers);}
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
const [{host,renderers}] = useState(() => {
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
const host=createRuntimeHost({ registry,
/** 每身份作用域绑定transport,current护栏由host提供,流读取也要防换账户后的迟到结果。 */
modelLoader(accountId,workspaceId,_authEpoch,current){return createFoundryModelLoader({workspaceId,registry,transport:createFoundryTransport({accountId,isCurrentIdentity:current})});},
/** 工作区持有浏览器存储、摘要/解码池;root项目共享其身份范围,各自再装配业务端口。 */
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
/** 一项目一组协议适配器;summary策略只压缩已协调回执,不改变网络请求或原始操作身份。 */
adapters(rootId, current) {
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
return { runs: createFoundryRuns({ workspaceId, rootId, registry, transport }), persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport, resolvedReceiptPolicy:'summary' }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
},
// 同工作区错误用稳定通知id更新,不创建另一份任务状态;错误恢复仍须runtime明确操作。
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
});
},
@@ -31,7 +45,10 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
});
return {host,renderers};
});
// effect重放的代次令牌只保护宿主清理,不是账户authEpoch,也不进入持久模型。
const lifetime = useRef(0);
/** cleanup延到微任务检查代次:Strict Mode同步重设effect会递增代次,旧cleanup不关闭新挂载宿主。
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
useEffect(() => {
const generation = ++lifetime.current;
// Strict Mode replays effects synchronously; only a real detach retires the host.
@@ -39,6 +56,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
}, [host]);
return <Context.Provider value={host}><Renderers.Provider value={renderers}>{children}</Renderers.Provider></Context.Provider>;
}
/** 需要runtime的页面必须在根Provider内,缺失装配明确抛错而不偷建一个临时host。 */
export function useCanvasRuntimeHost() {
const host = useContext(Context);
if (!host) throw new Error("CanvasRuntimeHost is missing");
@@ -1,8 +1,13 @@
/**
* 工作台标题的局部交互状态机:idle→selected→editing→saving,支持二次点击/F2编辑、Enter/失焦保存、Escape取消。
* 只管理草稿、焦点和提示,真正改名由异步onSave负责;组件本身不修改目录或画布文档。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./editable-title.module.css";
/** editable控制能否进入编辑;title是宿主已采用值,draft只在一次编辑会话内使用,保存失败回editing重试。 */
export function EditableTitle({ title, editable, onSave, className = "" }: {
title: string;
editable: boolean;
@@ -15,11 +20,14 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
const shell = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const button = useRef<HTMLButtonElement>(null);
// ref防Enter和随后的blur重复调用;cancelled防Escape/已完成后的blur重新提交。
const pending = useRef(false);
const cancelled = useRef(false);
/** selected阶段才监听外侧pointerdown;离开此模式即退订,编辑/保存不由这条外侧点击逻辑擅自退出。 */
useEffect(() => {
if (mode !== "selected") return;
/** 只在实际DOM目标落在shell外时取消选择,标题内部点击保留进入编辑的机会。 */
const deselect = (event: PointerEvent) => {
if (event.target instanceof Node && !shell.current?.contains(event.target)) setMode("idle");
};
@@ -27,10 +35,12 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
return () => document.removeEventListener("pointerdown", deselect);
}, [mode]);
/** 进入editing后挂载的input才可聚焦/全选,不在渲染阶段直接访问未就绪DOM。 */
useEffect(() => {
if (mode === "editing") { input.current?.focus(); input.current?.select(); }
}, [mode]);
/** 每次从宿主最新title初始化草稿并清旧错误,不能沿用上次取消的输入。 */
function edit() {
if (!editable) return;
cancelled.current = false;
@@ -38,12 +48,15 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setError("");
setMode("editing");
}
/** 标记取消以抑制blur保存,回selected并下一帧把焦点还给标题按钮。 */
function cancel() {
cancelled.current = true;
setError("");
setMode("selected");
requestAnimationFrame(() => button.current?.focus());
}
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
* 成功回idle,失败留草稿/错误重编辑,finally释放pending允许下一次明确尝试。 */
async function save() {
if (pending.current || cancelled.current) return;
const name = draft.trim();
@@ -65,6 +78,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setMode("editing");
} finally { pending.current = false; }
}
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
return (
<div ref={shell} className={`${styles.shell} ${className}`}>
{mode === "editing" || mode === "saving" ? (
+28
View File
@@ -1,3 +1,7 @@
/**
* 画布路由的客户端生命周期桥。把路径引用解析成board/profile,再选择新版engine、渐进预览或旧snapshot编辑器。
* 页面只持显示与导航状态,项目由RuntimeHost/WorkspaceRuntime拥有;卸载中断本页订阅,但已开始的项目加载可转后台完成。
*/
"use client";
import { useEffect, useRef, useState } from "react";
@@ -16,11 +20,16 @@ import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import dynamic from "next/dynamic";
/** 旧编辑器只在浏览器加载,避免服务端执行依赖DOM的画布实现。 */
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
/** 新版编辑器同样独立客户端bundle,模型选择由loadCanvasEntry结果决定,而非界面手动猜格式。 */
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
/** 只读渐进预览先出现,已知内容足够后可升级为partial engine,保持原视口。 */
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
/** route将结果绑定到这次路径;engine/snapshot/migration分别来自实际选择的协议路径。 */
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
/** 当前页面只借用host与renderer,retry触发重新读取;不在页面state复制业务文档或history。 */
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const renderers=useCanvasRenderers();
const demo = useSearchParams().get("demo") === "1";
@@ -30,12 +39,15 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
// 预览视口暂存于ref,切成可编辑视图时交作initialViewport;新路由重置,不当文档编辑保存。
const previewViewport=useRef<Viewport|undefined>(undefined);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const navigationGeneration=useRef(0);
/** 每条路由/重试新建读取生命周期,清旧显示;active、AbortController和host身份护栏共同隔离迟到结果。 */
useEffect(() => {
setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
// 演示目录走旧本地画布,不发canonical读取、不绑定真实账户项目。
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -44,46 +56,62 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}
let active = true;
const controller = new AbortController();
// 保存开始时身份,并在profile解析后取得currentIdentity闭包,后续await返回均据此判断是否还能采用结果。
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
// latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
const completeInBackground=()=>{if(partialEngine?.loading.getState()&&currentIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
/** 串行采用最新预览,先openPartial再发布UI并保存;期间新预览覆盖latest等待槽而非并行改根。 */
function pump(){
if(pumping)return pumping;
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&&currentIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
}
// 导航代次递增传入共享loader,查询身份与取消订阅由host/loader维护;入口优先canonical防旧writer误开。
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
/** 只有本board已在前缀中才显示;有元素后推动partial engine,尚未建立engine时用只读预览。 */
onPreview:(value,board,profile)=>{if(active&&currentIdentity?.()&&value.document.canvases.has(board.id)){if(!partialEngine)setPreview({board,value,route});if(value.document.elements.size){latest={value,board,accountId:profile.id};void pump().catch(reportBackground);}}},
/** profile解析后绑定账户并读取模型;若开始后的authEpoch已变且当前账户不同于响应账户,拒绝旧导航认领。 */
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
}).then(async result => {
if (!active||currentIdentity&&!currentIdentity()) return;
// 迁移完成回调只递增retry重开入口,不在旧组件内手工切换文档格式。
if (result.mode === 'legacy') {
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&&currentIdentity?.())setRetry(value=>value+1);}};
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
}
// 服务已返回新模型但本地仍有同根pending时,查询并收尾原迁移记录,不生成新迁移请求。
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
catch(error){reportBackground(error);}
}
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
while(pumping)await pumping;
if(!active||!currentIdentity?.())return;
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
})
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
// 清掉未采用预览并中断本页读取;已拥有的partial项目交后台,绝不在这里直接engine.close。
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
const current = loaded?.route === route ? loaded : null;
const currentPreview=preview?.route===route?preview:null;
const board = current?.board??currentPreview?.board;
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */
useEffect(() => {
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, workspaceId, setActiveBoard]);
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
// 显示优先级:engine→只读预览→旧snapshot→读取占位;重试错误提示与已显示前缀可并存。
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&currentPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
+5
View File
@@ -1,8 +1,13 @@
/**
* 头像的轻量显示降级:指定URL加载失败时用名字首段的大写表示,新的URL可重新尝试。
* 失败标记仅属当前组件,不写用户资料,也不把一次图片失败当成账户失效。
*/
"use client";
import { useState } from "react";
import styles from "./workspace.module.css";
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
export function UserAvatar({ name, url, large = false }: {
name: string;
url?: string | null;