Skip to content

项目工作台与工作区

本章目标:

  1. 看懂 /workspace/projects/[id] 路由组的三层 layout 嵌套(QueryProvider → ProjectLayout → (workspace) 三栏)如何把 SubNav / 子页 / 大管家拼成一个工作台
  2. 弄清 ProjectSubNav 五分组导航的 active 判定、知识库内嵌树、移动端 Sheet 适配
  3. 掌握"项目入口记忆"机制:localStorage 如何记住每个项目上次停留的 tab,以及 TanStack Query + Jotai 在工作区里的分工

TL;DR

项目工作区是 /workspace/projects/[id] 路由组,三层 layout 自外向内嵌套:projects/layout.tsx 注入 QueryProvider(TanStack Query 客户端)→ [id]/layout.tsxAutoCollapseMainSidebar(收外层主侧栏 + 启动导航记忆)→ (workspace)/layout.tsx 渲染三栏 ProjectSubNav | main | ProjectAgentChatPanel。各子页(概览/文档/团队/calendar/reports/ba-report/wiki/activity)是 server component 解 params 后委托 client 容器。ProjectSubNav 用静态 buildSections 生成五分组,知识库分组特殊——内嵌递归 WikiTree。导航记忆靠 last-project-location.ts 一套纯函数,把 (projectId, tab) 写进 localStorage,sidebar「我的项目」与 picker 卡片读回拼 URL,实现"切回上次位置"。状态分工:服务端数据全走 TanStack Query(useProject 等),纯前端 UI 态(会议抽屉 / 日历过滤)走 Jotai atom。

Overview(为什么需要"工作区"这一层抽象)

A-CDM 是多项目协作系统。一个用户可能同时是 A 项目的 owner、B 项目的 member,每个项目下又分概览、文档、团队、智能会议、WBS、知识库等近十个功能模块。如果不引入"项目工作区"这层结构,直接做扁平路由,会撞上三个问题:

  1. 上下文丢失:从会议报告页跳到 ERP 分析页,如果没有常驻的项目侧栏,用户会"迷路"——不知道自己还在哪个项目里,也回不到其它入口。
  2. 重复请求与状态割裂:每个子页都要知道"当前项目是谁"(名字、my_role、是否挂了知识库)。若各页各自拉取,既浪费请求,角色判定逻辑也会散落各处。
  3. 入口体验割裂:用户切到全局聊天再切回来,主导航「我的项目」永远跳项目选择页,得重新挑一遍上次正用的项目和 tab。

工作区这层的答案是:用嵌套 layout 提供"项目作用域"。Next.js App Router 的 layout 在子路由切换时不重挂,所以把 ProjectSubNav(项目导航)+ ProjectAgentChatPanel(项目大管家)放进 (workspace)/layout.tsx,用户在项目内任意子页间跳转,两侧栏保持稳定;QueryProvider 放最外层,useProject(id) 的结果被 TanStack Query 缓存,所有子页共享同一份项目元信息;再叠一个 last-project-location.ts 的 localStorage 记忆,解决跨模块回跳。

Architecture:三层 layout 嵌套

工作区的骨架是三层 layout 的洋葱式嵌套。每层职责单一,自外向内逐步缩小作用域:

文件类型职责Source
L1projects/layout.tsxserverQueryProvider,为整个 projects 子树(含 picker)提供 TanStack Query clientdeer-flow/frontend/src/app/workspace/projects/layout.tsx:9-11
L2[id]/layout.tsxserver渲染 AutoCollapseMainSidebar(收外层主侧栏 + 启动导航记忆),设置页面 <title>deer-flow/frontend/src/app/workspace/projects/[id]/layout.tsx:28-35
L3(workspace)/layout.tsxserver(async)await params 取 id,渲染三栏:SubNav / main / 大管家deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/layout.tsx:23-38
query-provider.tsxclientnew QueryClient(staleTime 30s,关闭 focus refetch)deer-flow/frontend/src/app/workspace/projects/[id]/query-provider.tsx:6-19

关键设计点是 Route Group (workspace)。括号目录在 Next.js 里不映射 URL 段,所以 /workspace/projects/<id>(概览)和 /workspace/projects/<id>/calendar(日历)都命中 (workspace)/layout.tsx + 内层对应 page.tsx。这让"概览页"与各功能子页共享同一个三栏框架,而 URL 仍保持扁平。[id]/layout.tsx 的注释明确写了为何把 AutoCollapseMainSidebar 挂在 L2 而非 L3:挂父级保证项目内任意子页切换不重挂(deer-flow/frontend/src/app/workspace/projects/[id]/layout.tsx:11-14)。历史上曾有独立的 (meetings) Route Group + MeetingsSubNav,在 redesign-smart-meeting-shell change 中删除,calendar/reports/meetings 平移进 (workspace) 共享同一项目侧栏,用户进会议页不再丢失项目其它入口(deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/layout.tsx:9-15)。

Components / Subsystems

ProjectSubNav(项目二级侧栏)

职责:项目作用域内的常驻导航,五个分组覆盖全部子页入口。

关键实现:ProjectSubNavInner(deer-flow/frontend/src/components/workspace/acdm/ProjectSubNav.tsx:188)外层 memo 包裹(:473)避免父级三栏 re-render 时无谓重绘。buildSections(projectId)(:119-180)是纯函数,按 projectId 拼出五个 section 的 href 与匹配规则。

每个 section 有一个 matches(pathname) 谓词决定它是否"命中当前路由"。注意 active 判定有两套:

  • section 级:matches 用前缀判断。例如智能会议分组 p.startsWith(.../calendar) || .../reports || .../records || .../meetings(:146-150)——四个不同子路由都归属"智能会议"分组,体现了 URL 扁平但语义分组的设计。
  • item 级:isItemActive(:182-186)。概览项 exact: true 走全等(否则任何子路由都会让概览高亮);其余项走"等于或以 / 开头"的前缀匹配。

expanded 状态初始全展开(:205-211),注释解释了设计权衡:内容不多,全展开有利于跨模块自然切换,原来"按 matches 自动折叠其它 section"的逻辑被主动撤销(:204)。路由变化只关 mobile Sheet,不重置 expanded(:219-221),用户手动 toggle 的折叠状态在项目内导航时持久。

知识库分组是唯一特殊的:items: [](:167),实际内容由动态 WikiTree 渲染。点知识库 section 时如果当前不在 wiki 路由,先 router.push(wikiBase)(:238-240),否则才 toggle 展开——因为右侧 WikiDualView 需要先进 wiki 路由才能渲染。wikiMountedproject.gitlab_wiki_path 是否非空决定(:198-199),未挂载时显示"去项目设置配置"的引导链接(带 ?settings=open query)。Ctrl/Cmd+K 只在 wiki 路由且已挂载时绑定唤起搜索(:224-234)。

AutoCollapseMainSidebar(双职责挂件)

职责:一个返回 null 的副作用组件,挂在 L2 layout,同时做两件事。

  1. 自动收起外层主侧栏:进入项目时 mount,若主侧栏当时是展开态则收起(deer-flow/frontend/src/components/workspace/acdm/AutoCollapseMainSidebar.tsx:25-41)。effect 的依赖数组为空,只在 mount/unmount 各执行一次,用闭包变量 wasOpen 截取"进入前状态",卸载时按进入前状态恢复——注释明确这不破坏用户偏好(进项目前已收起则离开后保持收起)。
  2. 启动导航记忆:调用 useTrackProjectLocation()(:23)。注释解释了为何挂这里:(workspace)/layout 是 server component 不能跑 hook,而这个 client 组件挂在 [id] 级别,能覆盖全部项目子路由(含 (workspace) 之外的 /schedule)。

子页(server 解参 → client 容器模式)

各子页统一遵循一个模式:page.tsx 是 server component(或薄 client),只负责 await params / use(params) 取 id,再委托真正的 client 容器组件。这套分工让 SSR 阶段就能解出路由参数,业务逻辑集中在 client 容器里。

子页路由 seg入口 page委托容器Source
(空) 概览(workspace)/page.tsx(client)inline ProjectOverviewPage + SOW 上传区deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/page.tsx:42-160
teamteam/page.tsx(client)inline ProjectTeamPage + InviteMemberDialogdeer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/team/page.tsx:51-143
calendarcalendar/page.tsx(server)ProjectMeetingsViewdeer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/calendar/page.tsx:3-14
reportsreports/page.tsx(server)ProjectReportsViewdeer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/reports/page.tsx:17-24
ba-reportsba-reports/page.tsx(server)BaReportContainerdeer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/ba-reports/page.tsx:13-20
wikiwiki/page.tsx(client)WikiDualViewEmptyWikiMountStatedeer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/wiki/page.tsx:26-64

概览页 page.tsx 还演示了一个 query 协议:接 ?settings=open 时自动开项目设置弹窗,然后用 router.replace 把 query 清掉防止刷新重复打开(deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/page.tsx:59-67)——这正是 ProjectSubNav 知识库未挂载提示里那个"去项目设置配置 →"链接的对端。

Data Flow:项目入口记忆(记住上次 tab)

导航记忆是工作区最有意思的子系统。它解决"用户切走再回来要回到上次位置"。整套逻辑集中在 last-project-location.ts(deer-flow/frontend/src/core/acdm/last-project-location.ts)的一组纯函数,无 React 依赖,可单测。

存储结构(:15-18):

ts
type LastProjectLocation = {
  lastActiveProjectId: string | null;
  tabByProject: Record<string /* projectId */, string /* tabSlug | "" */>;
};

写入 key acdm:lastProjectLocation:v1(:12)。tabparseTabFromPathname(:119-130)从 pathname 解析:/workspace/projects/<id>""(概览),/workspace/projects/<id>/<seg><seg>。所有 localStorage 访问都走 safeLocalStorage()(:25-32)try/catch 包裹——隐私模式 / quota 满时静默退化,不抛错。

三个触点串起整条链路:

  1. 写入useTrackProjectLocation(deer-flow/frontend/src/hooks/use-track-project-location.ts:18-26)在 pathname 变化时 setLastProjectLocation(projectId, tab)。它挂在 AutoCollapseMainSidebar 里(L2 级),覆盖全部项目子路由。
  2. 读回(sidebar)WorkspaceNavChatListuseProjectsHref(deer-flow/frontend/src/components/workspace/workspace-nav-chat-list.tsx:33-45)。SSR 阶段一律返回静态 /workspace/projects 防 hydration mismatch,mount 后 useEffect 才读 localStorage 覆盖 href。
  3. 读回 + 清理(picker)ProjectsPage(deer-flow/frontend/src/app/workspace/projects/page.tsx:40-54)mount 时读初值,项目列表加载完后 pruneTabByProject(ids) 过滤掉已删项目的 stale 记录;每张卡片 href={buildProjectUrl(p.id, tab)}(:112),点击时 setLastProjectLocation 顺手刷 lastActiveProjectId

getLastProjectLocation 还内置了 legacy 迁移:旧版只存项目 id 到 acdm:last-visited-project(LEGACY_KEY,:13),首次读到旧 key 时迁移成新结构并删旧(:64-78)。

状态管理:TanStack Query 与 Jotai 的分工

工作区有一条清晰的状态分界线:服务端数据走 TanStack Query,纯前端 UI 态走 Jotai atom

服务端数据由 core/acdm/hooks.ts 的一族 hook 封装,按数据弱变化程度调优 staleTime(deer-flow/frontend/src/core/acdm/hooks.ts:54-58):

HookqueryKeystaleTime用途Source
useProjects()["acdm","projects"]5 minpicker 列表deer-flow/frontend/src/core/acdm/hooks.ts:61-67
useProject(id)["acdm","project",id]5 min工作区共享的项目元信息deer-flow/frontend/src/core/acdm/hooks.ts:69-76
useUpdateProject(id)mutation改完 invalidate projects + projectdeer-flow/frontend/src/core/acdm/hooks.ts:86-95
useProjectStats(id)["acdm","project-stats",id]30s项目统计deer-flow/frontend/src/core/acdm/hooks.ts:107-114

useProject(id) 是工作区的"项目上下文之源":ProjectSubNav 用它拿 name / gitlab_wiki_path / my_role,概览页用它拿 sow_data,team 页用它判 my_role === "owner"。因为 staleTime 5 分钟且 queryKey 按 id 缓存,工作区内来回切子页都命中同一份缓存,不重复请求。QueryProvider 全局默认 staleTime 30s(deer-flow/frontend/src/app/workspace/projects/[id]/query-provider.tsx:13),业务 hook 各自覆盖。

Jotai atom 只用于跨组件但纯前端的 UI 态,代码注释明确解释了为何不用 React state:

Atom分片维度持久化为何用 atomSource
selectedMeetingIdAtom全局(同时只开一个抽屉)抽屉触发源多(日历点击 / 深链 / 通知),用 atom 解耦deer-flow/frontend/src/core/acdm/meeting-atoms.ts:13-16
previewFileAtom全局触发处与渲染处(Portal 到 body)非父子关系deer-flow/frontend/src/core/acdm/meeting-atoms.ts:26-48
checkedCalendarIdsByProjectAtom按 projectId 分片否(切 session 重置)防跨项目切换串味deer-flow/frontend/src/core/acdm/calendar-filter.ts:17-85

checkedCalendarIdsByProjectAtom 的设计尤其值得看:它持有 Record<projectId, checkedIds>,useCheckedCalendarIds(projectId)(deer-flow/frontend/src/core/acdm/calendar-filter.ts:42-85)封装单项目视图,computeNextCheckedIds(:26-36)是抽出来的纯函数(current === undefined 首次全勾,否则保留已勾 + append 新增),便于单测且 ensureAllChecked 用它做等价短路避免无谓 re-render(:71-78)。注意导航记忆(localStorage)和这些 atom 是两套不同机制——前者跨 session 持久,后者刻意不持久。

Configuration

配置位置默认含义Source
QueryClient staleTimequery-provider.tsx30s工作区全局默认,业务 hook 覆盖deer-flow/frontend/src/app/workspace/projects/[id]/query-provider.tsx:13
refetchOnWindowFocusquery-provider.tsxfalse关掉窗口聚焦自动重拉deer-flow/frontend/src/app/workspace/projects/[id]/query-provider.tsx:14
STORAGE_KEYlast-project-location.tsacdm:lastProjectLocation:v1导航记忆 localStorage keydeer-flow/frontend/src/core/acdm/last-project-location.ts:12
LEGACY_KEYlast-project-location.tsacdm:last-visited-project旧版 key,自动迁移后删除deer-flow/frontend/src/core/acdm/last-project-location.ts:13
COLLAPSED_KEYProjectAgentChatPanel.tsxacdm.project-agent-panel.collapsed大管家折叠态持久deer-flow/frontend/src/components/workspace/acdm/ProjectAgentChatPanel.tsx:57
STALE_PROJECThooks.ts5 min项目元信息缓存时长deer-flow/frontend/src/core/acdm/hooks.ts:56

Common Pitfalls / 实战 Tips

  • 改 ProjectSubNav 分组别动 active 逻辑:section 用前缀 matches、概览 item 用 exact: true,这是有意区分(ProjectSubNav.tsx:182-186)。把概览改成前缀匹配会导致任何子路由都让"项目概览"高亮。
  • Tailwind 颜色 token 必须写完整 class:COLORS 常量注释明确(ProjectSubNav.tsx:87)——Tailwind 编译期 tree-shake,动态拼 bg-${x}-100 不工作,所以五个分组的色板写死完整 class 名。
  • 导航记忆 SSR 必须先返 fallback:useProjectsHref 初始 state 必须是静态 /workspace/projects,mount 后才读 localStorage(workspace-nav-chat-list.tsx:35-43)。直接 SSR 读 localStorage 会 hydration mismatch。
  • localStorage 不可用要静默退化:所有访问走 safeLocalStorage()(last-project-location.ts:25-32)。隐私模式下不能抛错,退回老路径即可。
  • 非白名单 tab seg 也照存:parseTabFromPathname 不做 seg 枚举校验(设计文档 docs/superpowers/specs/2026-05-12-project-entry-last-tab-memory-design.md:59-60)——未来新增 tab 不用改解析,fail-fast 由后端 404 + picker prune 兜底。
  • Jotai atom 不持久,localStorage 才持久:日历过滤、会议抽屉刻意不持久(calendar-filter.ts:7-9),硬刷重置;只有 (projectId, tab) 导航记忆和大管家折叠态进 localStorage,别混淆两套机制。

References

  • deer-flow/frontend/src/app/workspace/projects/layout.tsx:1-12 — L1 layout,注入 QueryProvider
  • deer-flow/frontend/src/app/workspace/projects/[id]/layout.tsx:28-35 — L2 layout,挂 AutoCollapseMainSidebar
  • deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/layout.tsx:23-38 — L3 三栏布局(SubNav/main/大管家)
  • deer-flow/frontend/src/components/workspace/acdm/ProjectSubNav.tsx:119-473 — 项目二级侧栏:五分组 + 知识库内嵌树 + 移动端 Sheet
  • deer-flow/frontend/src/components/workspace/acdm/AutoCollapseMainSidebar.tsx:19-44 — 收主侧栏 + 启动导航记忆双职责
  • deer-flow/frontend/src/core/acdm/last-project-location.ts:1-136 — 导航记忆纯函数(get/set/parse/prune/build + legacy 迁移)
  • deer-flow/frontend/src/hooks/use-track-project-location.ts:18-26 — pathname 变化时写 localStorage 的 hook
  • deer-flow/frontend/src/components/workspace/workspace-nav-chat-list.tsx:33-45 — 主导航「我的项目」读回上次位置
  • deer-flow/frontend/src/app/workspace/projects/page.tsx:31-140 — picker 列表页:读初值 + prune stale + 卡片拼 URL
  • deer-flow/frontend/src/core/acdm/hooks.ts:54-114 — 项目相关 TanStack Query hooks + staleTime 调优
  • deer-flow/frontend/src/core/acdm/meeting-atoms.ts:13-48 — 会议抽屉 / 文件预览 Jotai atom
  • deer-flow/frontend/src/core/acdm/calendar-filter.ts:17-85 — 按 projectId 分片的日历过滤 atom
  • docs/superpowers/specs/2026-05-12-project-entry-last-tab-memory-design.md:1-112 — 导航记忆设计文档(行为契约 / edge cases)
PageRelationship
前端技术栈与架构本章是该章 Next.js App Router / TanStack Query / Jotai 体系的具体落地
proxy 鉴权守卫与 API 反代本章工作区路由受该章 /workspace/* 守卫保护
会议域与 AI 原子抽取本章 calendar 子页委托的 ProjectMeetingsView 属该章会议域
BA 专家报告工作流本章 ba-report 子页委托 BaReportContainer 进入该章工作流
知识库与 Wiki 协编本章 wiki 分组内嵌 WikiTree、wiki 子页委托 WikiDualView 属该章
项目管理与资源授权本章 useProject().my_role 角色判定的后端授权层在该章
AI 消息流与 SSE 基础设施本章三栏右侧 ProjectAgentChatPanel 复用该章消息流原子组件

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析