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