主题
项目工作台与工作区
本章目标:
- 看懂
/workspace/projects/[id]路由组的三层 layout 嵌套(QueryProvider → ProjectLayout → (workspace) 三栏)如何把 SubNav / 子页 / 大管家拼成一个工作台- 弄清 ProjectSubNav 五分组导航的 active 判定、知识库内嵌树、移动端 Sheet 适配
- 掌握"项目入口记忆"机制:localStorage 如何记住每个项目上次停留的 tab,以及 TanStack Query + Jotai 在工作区里的分工
TL;DR
项目工作区是 /workspace/projects/[id] 路由组,三层 layout 自外向内嵌套:projects/layout.tsx 注入 QueryProvider(TanStack Query 客户端)→ [id]/layout.tsx 挂 AutoCollapseMainSidebar(收外层主侧栏 + 启动导航记忆)→ (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、知识库等近十个功能模块。如果不引入"项目工作区"这层结构,直接做扁平路由,会撞上三个问题:
- 上下文丢失:从会议报告页跳到 ERP 分析页,如果没有常驻的项目侧栏,用户会"迷路"——不知道自己还在哪个项目里,也回不到其它入口。
- 重复请求与状态割裂:每个子页都要知道"当前项目是谁"(名字、
my_role、是否挂了知识库)。若各页各自拉取,既浪费请求,角色判定逻辑也会散落各处。 - 入口体验割裂:用户切到全局聊天再切回来,主导航「我的项目」永远跳项目选择页,得重新挑一遍上次正用的项目和 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 |
|---|---|---|---|---|
| L1 | projects/layout.tsx | server | 包 QueryProvider,为整个 projects 子树(含 picker)提供 TanStack Query client | deer-flow/frontend/src/app/workspace/projects/layout.tsx:9-11 |
| L2 | [id]/layout.tsx | server | 渲染 AutoCollapseMainSidebar(收外层主侧栏 + 启动导航记忆),设置页面 <title> | deer-flow/frontend/src/app/workspace/projects/[id]/layout.tsx:28-35 |
| L3 | (workspace)/layout.tsx | server(async) | await params 取 id,渲染三栏:SubNav / main / 大管家 | deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/layout.tsx:23-38 |
| — | query-provider.tsx | client | new 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 路由才能渲染。wikiMounted 由 project.gitlab_wiki_path 是否非空决定(:198-199),未挂载时显示"去项目设置配置"的引导链接(带 ?settings=open query)。Ctrl/Cmd+K 只在 wiki 路由且已挂载时绑定唤起搜索(:224-234)。
AutoCollapseMainSidebar(双职责挂件)
职责:一个返回 null 的副作用组件,挂在 L2 layout,同时做两件事。
- 自动收起外层主侧栏:进入项目时 mount,若主侧栏当时是展开态则收起(
deer-flow/frontend/src/components/workspace/acdm/AutoCollapseMainSidebar.tsx:25-41)。effect 的依赖数组为空,只在 mount/unmount 各执行一次,用闭包变量wasOpen截取"进入前状态",卸载时按进入前状态恢复——注释明确这不破坏用户偏好(进项目前已收起则离开后保持收起)。 - 启动导航记忆:调用
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 |
team | team/page.tsx(client) | inline ProjectTeamPage + InviteMemberDialog | deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/team/page.tsx:51-143 |
calendar | calendar/page.tsx(server) | ProjectMeetingsView | deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/calendar/page.tsx:3-14 |
reports | reports/page.tsx(server) | ProjectReportsView | deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/reports/page.tsx:17-24 |
ba-reports | ba-reports/page.tsx(server) | BaReportContainer | deer-flow/frontend/src/app/workspace/projects/[id]/(workspace)/ba-reports/page.tsx:13-20 |
wiki | wiki/page.tsx(client) | WikiDualView 或 EmptyWikiMountState | deer-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)。tab 由 parseTabFromPathname(:119-130)从 pathname 解析:/workspace/projects/<id> → ""(概览),/workspace/projects/<id>/<seg> → <seg>。所有 localStorage 访问都走 safeLocalStorage()(:25-32)try/catch 包裹——隐私模式 / quota 满时静默退化,不抛错。
三个触点串起整条链路:
- 写入 —
useTrackProjectLocation(deer-flow/frontend/src/hooks/use-track-project-location.ts:18-26)在 pathname 变化时setLastProjectLocation(projectId, tab)。它挂在AutoCollapseMainSidebar里(L2 级),覆盖全部项目子路由。 - 读回(sidebar) —
WorkspaceNavChatList的useProjectsHref(deer-flow/frontend/src/components/workspace/workspace-nav-chat-list.tsx:33-45)。SSR 阶段一律返回静态/workspace/projects防 hydration mismatch,mount 后useEffect才读 localStorage 覆盖 href。 - 读回 + 清理(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):
| Hook | queryKey | staleTime | 用途 | Source |
|---|---|---|---|---|
useProjects() | ["acdm","projects"] | 5 min | picker 列表 | 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 + project | deer-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 | 分片维度 | 持久化 | 为何用 atom | Source |
|---|---|---|---|---|
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 staleTime | query-provider.tsx | 30s | 工作区全局默认,业务 hook 覆盖 | deer-flow/frontend/src/app/workspace/projects/[id]/query-provider.tsx:13 |
refetchOnWindowFocus | query-provider.tsx | false | 关掉窗口聚焦自动重拉 | deer-flow/frontend/src/app/workspace/projects/[id]/query-provider.tsx:14 |
STORAGE_KEY | last-project-location.ts | acdm:lastProjectLocation:v1 | 导航记忆 localStorage key | deer-flow/frontend/src/core/acdm/last-project-location.ts:12 |
LEGACY_KEY | last-project-location.ts | acdm:last-visited-project | 旧版 key,自动迁移后删除 | deer-flow/frontend/src/core/acdm/last-project-location.ts:13 |
COLLAPSED_KEY | ProjectAgentChatPanel.tsx | acdm.project-agent-panel.collapsed | 大管家折叠态持久 | deer-flow/frontend/src/components/workspace/acdm/ProjectAgentChatPanel.tsx:57 |
STALE_PROJECT | hooks.ts | 5 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,注入 QueryProviderdeer-flow/frontend/src/app/workspace/projects/[id]/layout.tsx:28-35— L2 layout,挂 AutoCollapseMainSidebardeer-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— 项目二级侧栏:五分组 + 知识库内嵌树 + 移动端 Sheetdeer-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 的 hookdeer-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 + 卡片拼 URLdeer-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 atomdeer-flow/frontend/src/core/acdm/calendar-filter.ts:17-85— 按 projectId 分片的日历过滤 atomdocs/superpowers/specs/2026-05-12-project-entry-last-tab-memory-design.md:1-112— 导航记忆设计文档(行为契约 / edge cases)
Related Pages
| Page | Relationship |
|---|---|
| 前端技术栈与架构 | 本章是该章 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 复用该章消息流原子组件 |