From 71bf8b0f5e19cae72f550fe16875e80894c75657 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 23:57:45 +0800 Subject: [PATCH] feat: unify workspace sidebar and create Foundry graph pages --- README.md | 14 +- VALIDATION.md | 11 + .../dashboard/[workspace]/[board]/page.tsx | 11 + src/app/dashboard/dashboard.tsx | 343 +++++++----------- src/app/dashboard/layout.tsx | 14 + src/app/dashboard/page.tsx | 14 +- src/components/ui/icon-button.module.css | 18 + src/components/ui/icon-button.tsx | 27 ++ src/components/workspace/graph-page.tsx | 58 +++ src/components/workspace/workspace.module.css | 53 +++ src/lib/api.ts | 1 + src/lib/demo-workspace.ts | 15 + src/lib/workspace.ts | 21 +- tests/api-smoke.mjs | 33 +- tests/workspace.test.mjs | 22 +- 15 files changed, 417 insertions(+), 238 deletions(-) create mode 100644 src/app/dashboard/[workspace]/[board]/page.tsx create mode 100644 src/app/dashboard/layout.tsx create mode 100644 src/components/ui/icon-button.module.css create mode 100644 src/components/ui/icon-button.tsx create mode 100644 src/components/workspace/graph-page.tsx create mode 100644 src/components/workspace/workspace.module.css create mode 100644 src/lib/demo-workspace.ts diff --git a/README.md b/README.md index 27b4baf..99b19d3 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/VALIDATION.md b/VALIDATION.md index f74d88f..8f5339e 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -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 检查。 +- 按用户要求不访问浏览器,本次未验证实际界面交互或使用真实账户创建图谱;浏览器和线上验收由用户后续反馈。 diff --git a/src/app/dashboard/[workspace]/[board]/page.tsx b/src/app/dashboard/[workspace]/[board]/page.tsx new file mode 100644 index 0000000..0759de4 --- /dev/null +++ b/src/app/dashboard/[workspace]/[board]/page.tsx @@ -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 ( + 正在打开图谱…

}> + +
+ ); +} diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx index 2b9d1c5..5eb2cfc 100644 --- a/src/app/dashboard/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -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(null); const [workspaces, setWorkspaces] = useState([]); - const [workspace, setWorkspace] = useState(""); + const [selectedWorkspace, setSelectedWorkspace] = useState(""); const [boards, setBoards] = useState([]); - 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(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 ( -
- - + 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 = ; + + if (collapsed) return ( +
+ setCollapsed(false)} /> + {createButton} + {error &&

{error}

}
); + + return ( + + ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..91a92e9 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -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 ( +
+ }> + + + {children} +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a9c479c..8bf79c1 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,15 +1,3 @@ -import { Suspense } from "react"; -import Dashboard from "./dashboard"; - export default function DashboardPage() { - return ( - -
- }> - - - ); + return null; } diff --git a/src/components/ui/icon-button.module.css b/src/components/ui/icon-button.module.css new file mode 100644 index 0000000..4f057c3 --- /dev/null +++ b/src/components/ui/icon-button.module.css @@ -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; } diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx new file mode 100644 index 0000000..a778290 --- /dev/null +++ b/src/components/ui/icon-button.tsx @@ -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, "children"> & { + icon: LucideIcon; + label: string; +}; + +export const IconButton = forwardRef( + function IconButton({ icon: Icon, label, className = "", type = "button", ...props }, ref) { + return ( + + ); + }, +); diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx new file mode 100644 index 0000000..d67c26a --- /dev/null +++ b/src/components/workspace/graph-page.tsx @@ -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(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(`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 ( +
+ {error ?

{error}

: board ? ( + <> +

{board.title}

{demo && 演示}
+
+ + ) :

正在打开图谱…

} +
+ ); +} diff --git a/src/components/workspace/workspace.module.css b/src/components/workspace/workspace.module.css new file mode 100644 index 0000000..32dd3df --- /dev/null +++ b/src/components/workspace/workspace.module.css @@ -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; } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 577a5fa..96a30d6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -44,6 +44,7 @@ export const errorText = (error: unknown) => { workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。", workspace_required: "请先创建或选择一个工作空间。", invalid_workspace: "工作空间名称不符合要求,请修改后重试。", + forbidden: "你没有在此工作空间创建或编辑图谱的权限。", invalid_material: "画布名称或内容不符合要求,请修改后重试。", invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。", upstream_timeout: "连接服务超时,请稍后再试。", diff --git a/src/lib/demo-workspace.ts b/src/lib/demo-workspace.ts new file mode 100644 index 0000000..f65e5a3 --- /dev/null +++ b/src/lib/demo-workspace.ts @@ -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 })); +} diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 8dea658..ab5f31c 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -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 = (path: string, options?: RequestInit) => Promise; 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 }; } diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs index b9d3bd5..f22337e 100644 --- a/tests/api-smoke.mjs +++ b/tests/api-smoke.mjs @@ -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( ( diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs index c7dfb31..acfe432 100644 --- a/tests/workspace.test.mjs +++ b/tests/workspace.test.mjs @@ -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"); +});