feat: unify workspace sidebar and create Foundry graph pages

This commit is contained in:
2026-11-05 23:57:45 +08:00
parent 6ca4b00d2c
commit 71bf8b0f5e
15 changed files with 417 additions and 238 deletions
+10 -4
View File
@@ -1,6 +1,6 @@
# 啡跃 Endoleap
本地 Next.js App Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
本地 Next.js App Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台使用合并后的单侧栏,参考 Notion 的留白与账户菜单组织方式。Other Designs 用于首页叙事与版式参考。
项目目录:`/Users/zzyang/endoleap`。
@@ -18,13 +18,17 @@ npm run dev
- 登录回调:/auth/callback
- 真实工作台:/dashboard
- 本地演示:/dashboard?demo=1
- 无限图谱:/dashboard/{workspace}/{board}
## 路由结构
- `src/app/layout.tsx`:根布局、中文语言标记、全局样式与页面元信息。
- `src/app/page.tsx`:首页。
- `src/app/login/page.tsx`、`src/app/auth/callback/page.tsx`:账户页与登录回调。
- `src/app/dashboard/page.tsx`:工作台入口,以 Suspense 包裹读取查询参数的客户端工作台。
- `src/app/dashboard/layout.tsx`:共享单侧栏;`page.tsx` 保留空白工作区。
- `src/app/dashboard/dashboard.tsx`:侧栏收起/展开、页面搜索、新建图谱与底部账户弹出框。
- `src/app/dashboard/[workspace]/[board]/page.tsx`:按 workspace 和图谱 ID 打开页面,重新读取后端资料。
- `src/components/ui/icon-button.tsx`:Lucide 图标按钮,图标为 16×16px,上下 4px、左右 6px 内边距,总尺寸为 28×24px。
- `src/app/api/**/route.ts`:Foundry 代理与 OAuth 跳转,使用 App Router Route Handlers。
## 配置
@@ -43,8 +47,10 @@ Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目
- 响应式首页、锚点导航与 FAQ。
- 邮箱注册 / 登录 / 邮件验证兑换,Google 和 Slack 的 PKCE 登录入口及回调处理。
- 真实用户资料、空间列表与创建、画布列表与创建。资料和空间并行读取;首次创建画布时自动建立个人空间。
- 本地演示有示例画布、搜索、新建、画布界面、笔记浏览器保存、缩放、待办勾选和临时文字节点。
- 图形编辑、连接线、分享、Agent、收藏及完整布局持久化尚未接入。画布界面是交互预览,不是完整编辑器;演示数据不写入线上。
- 单侧栏第一行提供完全收起/恢复与新建页面,第二行为当前空间页面列表搜索,底部账户弹出框提供空间切换和退出登录。
- 新建页面使用 Foundry Core 的 `POST /workspaces/{workspace}/materials`,提交 `kind: board`、默认名称“未命名图谱”和内容版本;由后端生成 ID。没有空间时先创建“个人空间”,成功后跳转到图谱的独立地址。
- 本地演示提供示例图谱、搜索、新建和浏览器保存,支持图谱独立地址与刷新。
- 图形编辑、连接线、分享、Agent、收藏及完整布局持久化尚未接入。图谱页面目前只显示已保存的名称和空白图谱区域;演示数据不写入线上。
- 本地演示仅用于查看界面,不提供后端认证与权限。
Figma 原始 Logo、人物插画与图标已下载到 public/assets,不依赖临时 Figma URL。品牌保持啡跃 Endoleap;shadcn Button / Dialog 使用官方 registry 源码。依赖版本固定,package-lock.json 可用于 npm ci。
+11
View File
@@ -28,3 +28,14 @@
- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。
- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。
- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。
## 合并侧栏与新建图谱(2026-11-05)
- 移除最左侧独立 icon 栏,侧栏使用纵向 flex;顶部操作、搜索栏和底部账户按钮内部使用横向 flex。
- Lucide 图标按钮以 16×16px 图标、上下 4px / 左右 6px 内边距实现;收起后只保留左上角浮框,可恢复侧栏或新建页面。
- 账户弹出框使用原生 Popover,支持浏览器的外部点击及 Escape 关闭;菜单提供实际用户资料、空间切换和退出。
- 创建契约核对 Foundry Core 主线 `e8ed5e4` 的 materials/workspaces 实现:`kind=board`,后端生成 ID、workspace 归属、版本;前端提供默认名称“未命名图谱”,首次创建时可先建立“个人空间”。
- 类型检查通过;6 项 workspace 单元测试通过,包含默认名称、ID 和 workspace 保留、路由编码、首次建空间及失败重试。
- 在独立临时目录执行 `npm run build -- --webpack` 成功,未改写当前开发服务的构建缓存。
- 隔离生产构建的 API 冒烟测试通过,新增模拟 Foundry 的创建 201、会话转发、默认名称与 schema、按生成 ID 重新读取及图谱路由 200 检查。
- 按用户要求不访问浏览器,本次未验证实际界面交互或使用真实账户创建图谱;浏览器和线上验收由用户后续反馈。
@@ -0,0 +1,11 @@
import { Suspense } from "react";
import GraphPage from "@/components/workspace/graph-page";
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
const { workspace, board } = await params;
return (
<Suspense fallback={<p role="status">正在打开图谱…</p>}>
<GraphPage workspaceId={workspace} boardId={board} />
</Suspense>
);
}
+128 -215
View File
@@ -1,257 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
Plus,
Folder,
FileText,
LayoutGrid,
LogOut,
ChevronDown,
Check,
Sun,
Moon,
} from "lucide-react";
import { Brand } from "@/components/brand";
import { Icon } from "@/components/icon";
import { Check, ChevronsUpDown, FilePlus2, Home, LogOut, Network, PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import { api, errorText, type Profile } from "@/lib/api";
import { loadWorkspaceHome, type Workspace } from "@/lib/workspace";
import { boardHref, createBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
import { readDemoBoards } from "@/lib/demo-workspace";
import styles from "@/components/workspace/workspace.module.css";
type Board = { id: string; title: string };
const demoBoards: Board[] = [
{ id: "morning", title: "城市清晨" },
{ id: "ideas", title: "灵感收集" },
{ id: "journey", title: "下一站,去哪里" },
];
export default function Dashboard() {
export default function DashboardSidebar() {
const router = useRouter();
const demo = useSearchParams().get("demo") === "1";
const params = useParams<{ workspace?: string; board?: string }>();
const searchParams = useSearchParams();
const demo = searchParams.get("demo") === "1";
const [profile, setProfile] = useState<Profile | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [workspace, setWorkspace] = useState("");
const [selectedWorkspace, setSelectedWorkspace] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [view, setView] = useState("全部文件");
const [workspaceMenu, setWorkspaceMenu] = useState(false);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [dark, setDark] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(false);
const createLock = useRef(false);
const listVersion = useRef(0);
const accountPopup = useRef<HTMLDivElement>(null);
const workspace = params.workspace || searchParams.get("workspace") || selectedWorkspace;
useEffect(() => {
if (demo) {
setProfile({
id: "demo",
username: "creator",
display_name: "创作者",
email: null,
});
setWorkspaces([{ id: "demo", name: "个人空间" }]);
setWorkspace("demo");
setProfile({ id: "demo", username: "creator", display_name: "创作者", email: null });
setWorkspaces([{ id: "demo", name: "个人空间", role: "admin" }]);
setSelectedWorkspace("demo");
setStatus("ready");
return;
}
let active = true;
const controller = new AbortController();
setStatus("loading");
loadWorkspaceHome(api, controller.signal)
.then(({ profile, workspaces: list }) => {
.then(({ profile, workspaces }) => {
if (!active) return;
setProfile(profile);
setWorkspaces(list);
setWorkspace(list[0]?.id || "");
setWorkspaces(workspaces);
setSelectedWorkspace(workspaces[0]?.id || "");
setStatus("ready");
})
.catch((e) => {
if (!active) return;
setStatus("error");
setError(errorText(e));
if (e.message === "invalid_session") router.replace("/login");
else setError(errorText(e));
});
return () => {
active = false;
controller.abort();
};
return () => { active = false; controller.abort(); };
}, [demo, router]);
useEffect(() => {
if (!workspace) return;
setError("");
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
let active = true;
setBoards([]);
if (demo) {
try {
setBoards(
JSON.parse(
localStorage.getItem("endoleap_demo_boards_" + workspace) || "null",
) || demoBoards,
);
} catch {
setBoards(demoBoards);
}
setBoards(readDemoBoards(workspace));
return;
}
let active = true;
const controller = new AbortController();
api<{ items: Board[] }>(
"workspaces/" + workspace + "/materials?kind=board&sort=modified&limit=100",
{ signal: controller.signal },
)
.then((result) => {
if (active) setBoards(result.items);
})
.catch((e) => {
if (active) setError(errorText(e));
});
return () => {
active = false;
controller.abort();
};
}, [workspace, demo]);
api<{ items: Board[] }>(`workspaces/${encodeURIComponent(workspace)}/materials?kind=board&sort=modified&limit=100`, { signal: controller.signal })
.then(({ items }) => { if (active && version === listVersion.current) setBoards(items); })
.catch((e) => { if (active) setError(errorText(e)); });
return () => { active = false; controller.abort(); };
}, [workspace, demo, status]);
async function newPage() {
if (createLock.current || status !== "ready") return;
createLock.current = true;
setCreating(true);
setError("");
try {
const board = demo
? { id: crypto.randomUUID(), workspace_id: workspace, title: DEFAULT_BOARD_TITLE, kind: "board" as const, version: 1 }
: await createBoard(api, DEFAULT_BOARD_TITLE, workspace, (created) => {
setWorkspaces((list) => [...list, created]);
setSelectedWorkspace(created.id);
});
listVersion.current += 1;
setBoards((list) => [board, ...list]);
if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)]));
setQuery("");
router.push(boardHref(board.workspace_id, board.id, demo));
} catch (e) { setError(errorText(e)); }
finally { createLock.current = false; setCreating(false); }
}
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
router.push("/login");
} catch (e) {
setError(errorText(e));
}
} catch (e) { setError(errorText(e)); }
}
const names = workspaces.find((w) => w.id === workspace)?.name || "工作空间";
return (
<div className={"dashboard sidebar-only " + (dark ? "dark-workspace" : "")}>
<aside className="icon-rail">
<Link href="/" aria-label="首页">
<img
src="/assets/dashboardcontext-imgFrame.svg"
width="25"
height="25"
alt="啡跃"
/>
</Link>
<div className="rail-nav">
{[
["LayoutDashboard", "工作台"],
["Layers", "图层"],
["Search", "搜索"],
["ImagePlus", "素材"],
["Bot", "Agent"],
].map(([icon, label]) => (
<button
title={label}
aria-label={label}
className={label === "工作台" ? "active" : ""}
key={label}
disabled={label !== "工作台"}
onClick={() => setView("全部文件")}
>
<Icon name={icon} size={17} />
</button>
))}
</div>
<div className="rail-bottom">
<button aria-label="切换亮暗主题" onClick={() => setDark(!dark)}>
{dark ? <Sun size={17} /> : <Moon size={17} />}
</button>
<button aria-label="退出登录" title="退出登录" onClick={logout}>
<LogOut size={17} />
</button>
</div>
</aside>
<aside className="workspace-sidebar">
<div className="workspace-switch">
<button
onClick={() => setWorkspaceMenu(!workspaceMenu)}
aria-expanded={workspaceMenu}
>
<span className="workspace-avatar">↑</span>
<span>{names}</span>
<ChevronDown size={13} />
</button>
{workspaceMenu && (
<div className="workspace-menu">
{workspaces.map((w) => (
<button
key={w.id}
onClick={() => {
if (workspace !== w.id) setBoards([]);
setWorkspace(w.id);
setWorkspaceMenu(false);
setView("全部文件");
}}
>
{w.name}
{workspace === w.id && <Check size={14} />}
</button>
))}
<button disabled title="新建工作空间暂未开放">
<Plus size={14} /> 新建工作空间
</button>
</div>
)}
</div>
<div className="profile-row">
<span className="user-avatar">
{(profile?.display_name || profile?.username || "U")
.slice(0, 1)
.toUpperCase()}
</span>
<span>
{profile?.display_name || profile?.username || "创作者"}
</span>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="sidebar-label">工作空间</div>
{[
["全部文件", LayoutGrid],
["最近打开", FileText],
["我的收藏", Folder],
].map(([label, Comp]) => {
const C = Comp as typeof Folder;
return (
<button
key={label as string}
className={
"side-item " + (view === label ? "selected" : "")
}
onClick={() => setView(label as string)}
>
<C size={16} />
{label as string}
</button>
);
})}
<div className="sidebar-label sidebar-label-flex">
画布{" "}
<button
aria-label="新建画布"
disabled
title="新建画布暂未开放"
>
<Plus size={15} />
</button>
</div>
{boards.map((b) => (
<button
key={b.id}
className={"side-item " + (view === "board:" + b.id ? "selected" : "")}
onClick={() => setView("board:" + b.id)}
>
<Icon name="Scan1" size={15} />
{b.title}
</button>
))}
<button
className="side-item muted"
disabled
title="新建画布暂未开放"
>
<Plus size={15} />
新建画布
</button>
<div className="sidebar-bottom">
<p>给想法留白,给创作空间。</p>
<div className="sidebar-bottom-line">
<Brand />
{demo && <span className="demo-badge">演示</span>}
</div>
</div>
</aside>
const name = profile?.display_name || profile?.username || "账户";
const currentWorkspace = workspaces.find((w) => w.id === workspace);
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
if (collapsed) return (
<div className={styles.floating} aria-label="侧边栏快捷操作">
<IconButton icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} onClick={() => setCollapsed(false)} />
{createButton}
{error && <p className={styles.floatingError} role="alert">{error}</p>}
</div>
);
return (
<aside className={styles.sidebar} aria-label="工作空间侧边栏">
<div className={styles.topRow}>
<IconButton icon={PanelLeftClose} label="收起侧边栏" aria-expanded={true} onClick={() => { accountPopup.current?.hidePopover(); setCollapsed(true); }} />
{createButton}
</div>
<label className={styles.search}>
<Search size={16} strokeWidth={1.6} aria-hidden="true" />
<input aria-label="搜索页面" placeholder="搜索页面…" value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
<nav className={styles.navigation} aria-label="页面导航">
<Link className={styles.home} href={demo ? "/dashboard?demo=1" : "/dashboard"}>
<Home size={16} strokeWidth={1.6} /> 首页
</Link>
<div className={styles.sectionLabel}>{currentWorkspace?.name || "工作空间"}<span>{filtered.length}</span></div>
{status === "loading" && <p className={styles.hint} role="status">正在读取工作空间…</p>}
{status === "ready" && !workspace && <p className={styles.hint}>新建页面时会自动建立个人空间。</p>}
{filtered.map((board) => (
<Link key={board.id} className={styles.pageLink} aria-current={params.board === board.id ? "page" : undefined} href={boardHref(workspace, board.id, demo)}>
<Network size={16} strokeWidth={1.6} aria-hidden="true" />
<span>{board.title}</span>
</Link>
))}
{query && !filtered.length && <p className={styles.hint}>没有找到相关页面</p>}
</nav>
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.accountArea}>
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup}>
<div className={styles.accountHeading}><span className={styles.avatar}>{name.slice(0, 1).toUpperCase()}</span><div><strong>{name}</strong><small>{profile?.email || (demo ? "本地演示账户" : profile?.username)}</small></div></div>
<div className={styles.popupLabel}>工作空间</div>
{workspaces.map((w) => (
<button type="button" key={w.id} className={styles.menuItem} onClick={() => {
accountPopup.current?.hidePopover();
setQuery("");
setSelectedWorkspace(w.id);
router.push(`/dashboard?workspace=${encodeURIComponent(w.id)}${demo ? "&demo=1" : ""}`);
}}>
<span>{w.name}</span>{w.id === workspace && <Check size={14} />}
</button>
))}
<div className={styles.separator} />
<button type="button" className={styles.menuItem} onClick={logout}><LogOut size={16} />退出登录</button>
</div>
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<span className={styles.avatar}>{name.slice(0, 1).toUpperCase()}</span>
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
<ChevronsUpDown size={14} strokeWidth={1.6} />
</button>
</div>
</aside>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Suspense, type ReactNode } from "react";
import DashboardSidebar from "./dashboard";
import styles from "@/components/workspace/workspace.module.css";
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div className={styles.shell}>
<Suspense fallback={<aside className={styles.sidebar} aria-label="正在加载侧边栏" aria-busy="true" />}>
<DashboardSidebar />
</Suspense>
{children}
</div>
);
}
+1 -13
View File
@@ -1,15 +1,3 @@
import { Suspense } from "react";
import Dashboard from "./dashboard";
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="dashboard sidebar-only" aria-label="正在加载侧边栏" aria-busy="true">
<aside className="icon-rail" />
<aside className="workspace-sidebar" />
</div>
}>
<Dashboard />
</Suspense>
);
return null;
}
+18
View File
@@ -0,0 +1,18 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 24px;
padding: 4px 6px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
cursor: pointer;
}
.button svg { width: 16px; height: 16px; flex: 0 0 16px; }
.button:hover { background: #e9e7e3; }
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
.button:disabled { opacity: 0.45; cursor: not-allowed; }
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { forwardRef, type ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
import styles from "./icon-button.module.css";
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
icon: LucideIcon;
label: string;
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ icon: Icon, label, className = "", type = "button", ...props }, ref) {
return (
<button
ref={ref}
type={type}
aria-label={label}
title={label}
className={`${styles.button} ${className}`}
{...props}
>
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
</button>
);
},
);
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Network } from "lucide-react";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import type { Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const [board, setBoard] = useState<Board | null>(null);
const [error, setError] = useState("");
useEffect(() => {
setBoard(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("无法读取本地演示图谱。"); }
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");
});
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, demo, router]);
return (
<main className={styles.graph} aria-label="无限图谱">
{error ? <p className={styles.graphStatus} role="alert">{error}</p> : board ? (
<>
<header className={styles.graphHeader}><Network size={16} /><h1>{board.title}</h1>{demo && <span>演示</span>}</header>
<div className={styles.graphSurface} aria-label={board.title + "的空白图谱"} />
</>
) : <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>
);
}
@@ -0,0 +1,53 @@
.shell { display: flex; min-height: 100dvh; background: #fffefb; color: #55534f; }
.sidebar {
position: sticky; top: 0; display: flex; flex-direction: column;
width: 270px; max-width: calc(100vw - 40px); height: 100dvh; flex: 0 0 auto;
padding: 12px 10px 8px; background: #f8f7f5; border-right: 1px solid #edece9;
font-size: 13px; z-index: 30;
}
.topRow { display: flex; align-items: center; justify-content: space-between; margin: 0 0 12px; color: #85817b; }
.search { display: flex; align-items: center; gap: 7px; border: 1px solid #e6e3df; border-radius: 7px; padding: 7px 9px; color: #97938d; }
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font-size: 13px; color: #55534f; }
.search input::placeholder { color: #9a9690; }
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 14px; }
.home, .pageLink { display: flex; align-items: center; gap: 9px; padding: 8px; min-height: 33px; border-radius: 6px; }
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
.pageLink { margin: 2px 0; }
.pageLink svg { flex-shrink: 0; color: #96928b; }
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
.sectionLabel { display: flex; justify-content: space-between; padding: 26px 7px 8px; color: #9b9690; font-size: 11px; }
.hint { padding: 6px 8px; color: #9a948c; line-height: 1.7; font-size: 12px; }
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
.accountArea { margin-top: auto; padding-top: 10px; }
.accountButton { display: flex; align-items: center; gap: 9px; padding: 8px; width: 100%; border-radius: 7px; text-align: left; }
.accountButton:hover { background: #edeae5; }
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 27px; height: 27px; border-radius: 7px; background: #e8e0d6; color: #705844; font-size: 13px; font-weight: 600; }
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; gap: 2px; }
.accountText strong { font-size: 12px; font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountText small { font-size: 10px; color: #969089; }
.accountPopup {
position: fixed; inset: auto auto 64px 8px; margin: 0; width: 300px; max-width: calc(100vw - 16px);
padding: 8px; color: #55534f; background: #fff; border: 1px solid #e9e5df;
border-radius: 10px; box-shadow: 0 8px 30px #38302612; font-size: 13px;
}
.accountPopup::backdrop { background: transparent; }
.accountHeading { display: flex; align-items: center; gap: 9px; padding: 7px 6px 12px; border-bottom: 1px solid #efede9; }
.accountHeading > div { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.accountHeading strong { font-size: 13px; font-weight: 600; }
.accountHeading small { color: #969089; font-size: 11px; overflow-wrap: anywhere; }
.popupLabel { padding: 12px 7px 5px; color: #9a948c; font-size: 11px; }
.menuItem { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
.menuItem > span { flex: 1; }
.menuItem:hover { background: #f4f1ec; }
.separator { height: 1px; margin: 8px 0; background: #efede9; }
.floating { position: fixed; left: 12px; top: 12px; z-index: 40; display: flex; gap: 6px; padding: 6px; background: #fffefa; border: 1px solid #e7e2db; border-radius: 8px; box-shadow: 0 3px 14px #3c312b0d; color: #85817b; }
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
.graph { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100dvh; }
.graphHeader { display: flex; align-items: center; gap: 10px; padding: 20px 24px 20px 110px; min-height: 64px; border-bottom: 1px solid #efede8; font-size: 13px; }
.graphHeader h1 { margin: 0; font-size: 14px; font-weight: 500; }
.graphHeader span { color: #aaa299; }
.graphSurface { flex: 1; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
.graphStatus { padding: 80px 24px; color: #9a948c; text-align: center; font-size: 13px; }
@media (max-width: 600px) { .sidebar { width: 240px; } .graphHeader { padding: 20px 14px; } }
+1
View File
@@ -44,6 +44,7 @@ export const errorText = (error: unknown) => {
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
workspace_required: "请先创建或选择一个工作空间。",
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
upstream_timeout: "连接服务超时,请稍后再试。",
+15
View File
@@ -0,0 +1,15 @@
import type { Board } from "./workspace";
export function readDemoBoards(workspace: string): Board[] {
try {
const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null");
if (Array.isArray(stored)) return stored.map((board) => ({
...board, workspace_id: workspace, kind: "board", version: board.version || 1,
}));
} catch { /* Fall back to the sample pages when browser storage is invalid. */ }
return [
{ id: "morning", title: "城市清晨" },
{ id: "ideas", title: "灵感收集" },
{ id: "journey", title: "下一站,去哪里" },
].map((board) => ({ ...board, workspace_id: workspace, kind: "board", version: 1 }));
}
+18 -3
View File
@@ -1,6 +1,19 @@
import type { Profile } from "./api";
export type Workspace = { id: string; name: string };
export type Workspace = { id: string; name: string; role?: "admin" | "editor" | "viewer" };
export type Board = {
id: string;
workspace_id: string;
kind: "board";
title: string;
version: number;
content?: { schema_version: number };
};
export const DEFAULT_BOARD_TITLE = "未命名图谱";
export function boardHref(workspace: string, board: string, demo = false) {
return `/dashboard/${encodeURIComponent(workspace)}/${encodeURIComponent(board)}${demo ? "?demo=1" : ""}`;
}
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
export async function loadWorkspaceHome(
@@ -32,12 +45,14 @@ export async function createBoard(
id = workspace.id;
onWorkspaceCreated(workspace);
}
return request<{ id: string }>("workspaces/" + id + "/materials", {
const name = title.trim() || DEFAULT_BOARD_TITLE;
const result = await request<{ id: string; workspace_id: string; kind: "board"; version: number }>("workspaces/" + id + "/materials", {
method: "POST",
body: JSON.stringify({
kind: "board",
title: title.trim(),
title: name,
content: { schema_version: 1 },
}),
});
return { ...result, workspace_id: result.workspace_id || id, title: name };
}
+31 -2
View File
@@ -4,11 +4,25 @@ import { spawn } from "node:child_process";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
const host = "http://127.0.0.1:3005";
const upstream = createServer((req, res) => {
const graphs = new Map();
const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url.endsWith("/auth/logout")) {
res.statusCode = 204;
res.end();
} else if (req.url === "/projects/endoleap/workspaces/space/materials" && req.method === "POST") {
assert.equal(req.headers.authorization, "Bearer test-only-session");
let raw = "";
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } });
const graph = { ...input, id: "foundry-generated-id", workspace_id: "space", version: 1 };
graphs.set(graph.id, graph);
res.statusCode = 201;
res.end(JSON.stringify({ id: graph.id, workspace_id: graph.workspace_id, kind: graph.kind, version: graph.version }));
} else if (req.url === "/projects/endoleap/workspaces/space/materials/foundry-generated-id") {
assert.equal(req.headers.authorization, "Bearer test-only-session");
res.end(JSON.stringify(graphs.get("foundry-generated-id")));
} else if (req.url.includes("/workspaces/space/materials")) {
res.end(JSON.stringify({ url: req.url, method: req.method }));
} else if (req.url.endsWith("/auth/login")) {
@@ -69,7 +83,7 @@ try {
await new Promise((r) => setTimeout(r, 100));
}
assert(ready, "Frontend did not start");
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1"]) {
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) {
const page = await fetch(host + path);
assert.equal(page.status, 200, path);
const html = await page.text();
@@ -102,6 +116,21 @@ try {
200,
);
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
body: JSON.stringify({ kind: "board", title: "未命名图谱", content: { schema_version: 1 } }),
});
assert.equal(create.status, 201);
const graph = await create.json();
assert.deepEqual(graph, { id: "foundry-generated-id", workspace_id: "space", kind: "board", version: 1 });
const opened = await fetch(host + `/api/foundry/workspaces/${graph.workspace_id}/materials/${graph.id}`, { headers: { Cookie: session } });
assert.equal(opened.status, 200);
const stored = await opened.json();
assert.equal(stored.title, "未命名图谱");
assert.equal(stored.workspace_id, "space");
assert.equal(stored.content.schema_version, 1);
for (const origin of ["https://example.com", "null"])
assert.equal(
(
+21 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createBoard, loadWorkspaceHome } from "../src/lib/workspace.ts";
import { boardHref, createBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts";
test("profile and workspace reads both start before either completes", async () => {
const calls = [];
@@ -80,3 +80,23 @@ test("an existing workspace posts a board directly", async () => {
);
assert.deepEqual(calls, ["workspaces/existing/materials"]);
});
test("new graphs use a default name and retain the Foundry-assigned identity", async () => {
const created = await createBoard(async (path, options) => {
assert.equal(path, "workspaces/space/materials");
assert.deepEqual(JSON.parse(options.body), {
kind: "board", title: DEFAULT_BOARD_TITLE, content: { schema_version: 1 },
});
return { id: "server-generated-id", workspace_id: "space", kind: "board", version: 1 };
}, " ", "space", () => assert.fail("unexpected workspace creation"));
assert.equal(created.id, "server-generated-id");
assert.equal(created.workspace_id, "space");
assert.equal(created.title, "未命名图谱");
assert.equal(boardHref(created.workspace_id, created.id), "/dashboard/space/server-generated-id");
});
test("graph URLs preserve workspace identity and safely encode route segments", () => {
assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb");
assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1");
});