feat(workspace): integrate persisted canvas, element editing and session renewal

This commit is contained in:
2026-11-06 12:50:43 +08:00
parent 45e78f2e43
commit 373582906d
36 changed files with 1028 additions and 193 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { Board } from "@/lib/workspace";
type ActiveBoard = Pick<Board, "id" | "workspace_id" | "title"> | null;
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
const ActiveBoardContext = createContext<{
board: ActiveBoard;
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
+24 -42
View File
@@ -4,64 +4,46 @@ import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import type { Board } from "@/lib/workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadBoardCanvas } from "@/lib/canvas-loader";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import GraphCanvas from "@/components/canvas/graph-canvas";
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [board, setBoard] = useState<Board | null>(null);
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
setBoard(null);
setError("");
setLoaded(null); setError("");
if (demo) {
try {
const list = readDemoBoards(workspaceId);
const item = list.find((b) => b.id === boardId);
if (item) setBoard(item);
else setError("图谱不存在。");
} catch { setError("无法读取本地演示图谱。"); }
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
else setError("图谱不存在。");
return;
}
let active = true;
const controller = new AbortController();
api<Board>(`workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`, { signal: controller.signal })
.then((item) => {
if (!active) return;
if (item.kind !== "board" || item.workspace_id !== workspaceId) {
setError("当前页面不是此工作空间的图谱。");
return;
}
setBoard(item);
})
.catch((e) => {
if (!active) return;
setError(errorText(e));
if (e.message === "invalid_session") router.replace("/login");
});
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
.then((result) => { if (active) setLoaded({ ...result, route }); })
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, demo, router]);
}, [workspaceId, boardId, route, demo, retry, router]);
const current = loaded?.route === route ? loaded : null;
const board = current?.board;
useEffect(() => {
if (board?.id === boardId && board.workspace_id === workspaceId) {
setActiveBoard({ id: board.id, workspace_id: board.workspace_id, title: board.title });
}
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, boardId, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === workspaceId
? { ...board, title: activeBoard.title } : board;
return (
<main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={workspaceId + ":" + boardId} board={displayBoard} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <p className={styles.graphStatus} role="alert">{error}</p>
: !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>
);
}, [board, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <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>}
</main>;
}