chore(pmflow): integrate big module lane workbench

This commit is contained in:
Albert 2026-04-26 10:24:01 +08:00
parent f95bd15ff3
commit 4b2193e828
13 changed files with 2225 additions and 428 deletions

View File

@ -206,10 +206,14 @@ frontend/ # React 前端
## 17. Codex 并行开发治理 ## 17. Codex 并行开发治理
- 多个 Codex 线程并行推进时,执行单位必须是 `Packet`,不得以模糊“大模块”并行开工。 - 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / owner_thread / base_ref / read_scope / write_scope / forbidden_paths / shared_files / depends_on / merge_after / verification / docs_to_sync`。 - 每个 Packet 开始前必须有任务包,至少声明 `packet_id / module_lane / owner_thread / lane_owner / integration_owner / base_ref / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / depends_on / merge_after / verification / docs_to_sync`。
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。 - 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。 - 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
- 大模块并行时,`Shell / Integration owner` 负责共享路由、导航、Provider、共享 API base、共享文档和最终合并;模块线程只能写自己的 owned files。
- `Space` 线程只做个人空间聚合、草案箱、待确认摘要、AI 进行中摘要和跳转,不直接写 Project Memory、ChangeSet commit 或 Execution Bridge 回写。
- `Account Settings` 线程只做个人资料、偏好、通知、账户壳和设置入口,不直接改项目主链、权限核心、AI 路由或 Space 聚合。
- `Main Flow` 线程继续推进 PMFlow OS 主链,但不得顺手重做 Space 或个人中心。
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。 - 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。 - 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。 - Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
@ -225,7 +229,11 @@ frontend/ # React 前端
- `backend/app/schemas/*.py` 中的跨域 schema - `backend/app/schemas/*.py` 中的跨域 schema
- `backend/alembic/versions/*` - `backend/alembic/versions/*`
- `frontend/src/router/index.tsx` - `frontend/src/router/index.tsx`
- `frontend/src/main.tsx`
- `frontend/src/api/base.ts` - `frontend/src/api/base.ts`
- `frontend/src/lib/api.ts`
- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`
- `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`
- `frontend/src/features/pmflow-os/store.tsx` - `frontend/src/features/pmflow-os/store.tsx`
- `CLAUDE.md` - `CLAUDE.md`
- `docs/architecture/CODE_REFERENCE_INDEX.md` - `docs/architecture/CODE_REFERENCE_INDEX.md`

View File

@ -9,7 +9,7 @@
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。 - 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。 - 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
- 新增前端快回路约定:`L1 微调` 默认走 `verify:login-fast` 或 focused screenshot,`L2 视觉逻辑` 默认走 `verify:login-responsive` 或 `capture:login:matrix`,出现 `context_compacted` 或超过 `8` 分钟仍未落代码时优先收口或切线程。 - 新增前端快回路约定:`L1 微调` 默认走 `verify:login-fast` 或 focused screenshot,`L2 视觉逻辑` 默认走 `verify:login-responsive` 或 `capture:login:matrix`,出现 `context_compacted` 或超过 `8` 分钟仍未落代码时优先收口或切线程。
- 新增并行开发约定:多个 Codex 线程并行推进时必须按 Packet 分派,声明 `read_scope / write_scope / forbidden_paths / shared_files / base_ref`,默认使用 `codex/<packet_id>` 独立分支和独立 worktree;共享高冲突文件只能由 integration owner 统一合并。 - 新增并行开发约定:多个 Codex 线程可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行仍必须按 Packet 分派,声明 `module_lane / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / base_ref`,默认使用 `codex/<packet_id>` 独立分支和独立 worktree;共享路由、导航、Provider、API base 和文档只能由 integration owner 统一合并。当前 P0 已冻结 route 归属:`/space` 归 Space,`/pmflow-os / /command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow,`/me` 归 Account Settings,`/settings` 仅为 reserved route、尚未实现。
- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。 - 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。
## 2. 项目定位 ## 2. 项目定位
@ -110,12 +110,13 @@ frontend/
- 当前前端重点入口优先看: - 当前前端重点入口优先看:
- `frontend/src/api/`(统一 API 封装) - `frontend/src/api/`(统一 API 封装)
- `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏,登录后默认主入口已切到 `/command-center`,`/space`、`/project-launch`、`/pmflow-os` 与 `/me` 是可直接打开的个人空间 / 新项目启动台 / PMFlow OS 项目上下文 / 我的账号入口,`/command-center` 也提供免登录产品预览壳,其余后台重页面按 route-level lazy 进入) - `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏,登录后默认主入口已切到 `/command-center`,`/space`、`/project-launch`、`/pmflow-os` 与 `/me` 是可直接打开的个人空间 / 新项目启动台 / PMFlow OS 项目上下文 / 我的账号入口,`/command-center` 也提供免登录产品预览壳,其余后台重页面按 route-level lazy 进入)
- `frontend/src/features/personal-space/`(个人空间首页;本地 mock data 驱动继续推进、待我确认、AI 正在推进、暂存想法、草案箱和新建项目入口) - `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary,承载继续推进、待确认、AI 正在推进、草案箱和最近项目入口)
- `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机) - `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机)
- `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 调后端,显式 `?demo=1` 才启用本地演示状态机) - `frontend/src/features/account-settings/`(`/me` 个人中心与设置;通过 `frontend/src/api/accountSettings.ts` 管理个人资料、默认语言、AI 回答深度、证据展示偏好和通知偏好,移动端使用分组列表与 bottom sheet,不进入 auth core)
- `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;C1 不实现 commit) - `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 与 ChangeSet `commit` 调后端,显式 `?demo=1` 才启用本地演示状态机)
- `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1/C2 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer` 与 ChangeSet `commit`;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed)
- `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed) - `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed)
- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,commit 仍未实现) - `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,Phase C2 backend/frontend commit 已实现)
- `frontend/src/pages/inbox/`(消息台) - `frontend/src/pages/inbox/`(消息台)
- `frontend/src/pages/ai-steward/`(AI 管家) - `frontend/src/pages/ai-steward/`(AI 管家)
- `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示) - `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示)
@ -131,8 +132,9 @@ frontend/
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。 - 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;AI Steward / AI Config 页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。 - 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;AI Steward / AI Config 页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。
- `frontend/src/features/project-launch/` 是新项目启动台独立产品域:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,`mockData.ts` 只在显式 demo 模式下使用;默认路径调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=<id>`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。 - `frontend/src/features/project-launch/` 是新项目启动台独立产品域:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,`mockData.ts` 只在显式 demo 模式下使用;默认路径调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=<id>`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。
- `frontend/src/features/personal-space/` 是空间层入口:`mockData.ts` 固化本地项目、待确认、AI 推进、暂存想法与草案箱数据,`PersonalSpacePage.tsx` 承接桌面空间首页与独立移动布局,`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。 - `frontend/src/features/personal-space/` 是空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 并生成继续推进、待确认、AI 正在推进、草案箱、最近项目摘要;点击项目/确认/草案只跳回 `/pmflow-os?projectId=<id>` 主流程处理,不在 Space 写 Project Memory 或 execution event。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` API,成功后 refetch truth-state;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 不会进入 Project Memory,commit 留给 Phase C2。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。 - `frontend/src/features/account-settings/` 是 Account Settings 独立产品域:`AccountSettingsPanel.tsx` 承接 `/me` 桌面设置页、移动端分组列表与 bottom sheet,`frontend/src/api/accountSettings.ts` 定义个人资料、默认语言、AI 回答深度、证据展示偏好和四类通知偏好合同,当前首版使用浏览器本地持久化作为前端合同落点,不改 OAuth/JWT/auth core;`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 仅做薄入口并复用既有退出登录 bottom sheet。
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 与 ChangeSet `commit` API,成功后 refetch truth-state;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。
- 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。 - 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后) ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)

View File

@ -807,6 +807,53 @@ P0 公共壳冻结已完成,下一批可以进入 `Batch P1` 三模块并行
## 8. 进度台账 ## 8. 进度台账
### 2026-04-26 / big-module-integration-p1 / done
完成内容:
- 作为 `Shell / Integration owner` 收口 Batch P1 三条 lane:Main Flow D.2、Space V1 Workbench Hardening、Account Settings V1。
- 已确认当前分支为 `codex/main-flow-phase-d2-execution-handoff-contract`,Main Flow D.2 已落提交 `f95bd15 feat(pmflow-os): add execution handoff contract`,本轮不重写 D.2,只验证其与 Space / Account dirty worktree 的组合。
- 已检查 tracked diff 与 untracked files,当前脏文件均落在本次集成范围:`AGENTS.md / CLAUDE.md / TASK_BRIEF_TEMPLATE.md / ARCHITECTURE_INVENTORY.md / personal-space / account-settings / accountSettings.ts / space.ts / AccountPage.tsx`。
- Space 改动符合 P0 边界:`frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS `truth-state / execution-summary`,待确认项只跳转 `/change-review`,不直接写 Project Memory、ChangeSet 状态或 Execution Bridge event。
- Account Settings 改动符合 P0 边界:`/me` 由 `AccountSettingsPanel` 承接个人资料、AI 偏好和通知偏好,首版只做浏览器本机持久化,不改 OAuth/JWT/auth core、router、MobileBottomNav、PMFlow OS store 或项目主链。
- P0 文档残留已收口:`AGENTS.md / CLAUDE.md / TASK_BRIEF_TEMPLATE.md` 的大模块 lane、integration owner、共享文件锁与 Packet 模板规则可纳入 integration commit;`ARCHITECTURE_INVENTORY.md` 修正旧的 Space 只能消费 mock data / 禁止接后端描述,改为当前只读聚合合同。
- `CODE_REFERENCE_INDEX.md` 已确认包含 Space API、Account Settings API、PMFlow OS D.2 execution handoff API 索引;`CLAUDE.md` 已确认不再保留旧的“commit 未实现”描述。
涉及文件:
- `AGENTS.md`
- `CLAUDE.md`
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
- `docs/architecture/TASK_BRIEF_TEMPLATE.md`
- `frontend/src/api/space.ts`
- `frontend/src/api/accountSettings.ts`
- `frontend/src/features/personal-space/PersonalSpacePage.tsx`
- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`
- `frontend/src/features/personal-space/components/SpaceChrome.tsx`
- `frontend/src/features/account-settings/AccountSettingsPanel.tsx`
- `frontend/src/features/account-settings/accountSettings.test.ts`
- `frontend/src/features/pmflow-os/pages/AccountPage.tsx`
验证:
- `cd frontend && npm run test -- src/features/personal-space/PersonalSpacePage.test.tsx` 通过:`1 passed`。
- `cd frontend && npm run test -- src/features/account-settings/accountSettings.test.ts` 通过:`3 passed`。
- `cd frontend && npm run test -- src/features/pmflow-os` 通过:`4 passed`。
- `cd frontend && npm run typecheck` 通过。
- `cd frontend && npm run build` 通过;Vite 仍输出既有 manual chunk circular warning(`ai-steward / sstc / charts / governance`),未阻断构建。
- `PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q` 通过:`9 passed`;仍有既有 `backend/app/schemas/recipe.py` Pydantic V2 class-based config deprecation warning。
未完成或风险:
- 本 integration packet 未新增或修改后端运行时代码、Schema 或 migration;本轮未执行热部署/重启。当前验证发生在本地开发工作区,未连接实际部署服务;若要让 Main Flow D.2 的后端提交在部署环境生效,需要在对应部署机执行 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart` 或等价发布流程。
- Space 的 `savedIdeas` 后端只读聚合合同仍未实现;当前 UI 明确展示 empty/warning,不使用 mock 数据冒充真实内容。
- Account Settings V1 仍是前端合同 + 本机持久化;跨设备同步需要后续独立新增 `account_settings_api` 与持久化表。
下一步建议:
- 进入 D.3:补 Dev Sync 写回闭环,把 Codex / Local Bridge / GitHub agent 的 branch、commit、PR、test progress event 回写到 `execution-summary / truth-state`。
### 2026-04-26 / Space V1 Workbench Hardening / implemented ### 2026-04-26 / Space V1 Workbench Hardening / implemented
完成内容: 完成内容:

View File

@ -227,7 +227,7 @@
- AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。 - AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。
- 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。 - 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。
- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。 - `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。
- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页与新项目启动台同样是独立产品域,只能分别消费 `frontend/src/features/personal-space/` 的本地 mock data 与 `frontend/src/features/project-launch/` 的本地状态机,禁止在首版中接后端、真实 AI 调用、真实项目创建 API、团队空间强切换或完整企业组织管理流程。 - PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页、新项目启动台和 Account Settings 同样是独立产品域:Space 只能通过 `frontend/src/api/space.ts` 做项目摘要、PMFlow OS `truth-state / execution-summary` 的只读聚合并跳回主流程;Project Launch 默认走真实 draft/confirm 创建链,只有显式 demo 参数才使用本地状态机;Account Settings 只管理个人资料、偏好和通知,不改 OAuth/JWT/auth core、PMFlow OS truth store、Project Memory、ChangeSet 或 Execution Bridge 写回。
## 6. 工具与运行库存 ## 6. 工具与运行库存
- `backend/scripts/repair_requirement_plan_history.py` - `backend/scripts/repair_requirement_plan_history.py`

View File

@ -2,11 +2,16 @@
本模板用于 PMFlow Codex 单线程或并行 Packet 分派。并行任务必须填写 `Read Scope / Write Scope / Forbidden Paths`;没有 `write_scope` 的任务不允许并行执行。 本模板用于 PMFlow Codex 单线程或并行 Packet 分派。并行任务必须填写 `Read Scope / Write Scope / Forbidden Paths`;没有 `write_scope` 的任务不允许并行执行。
大模块并行时,`Module Lane` 表示长期产品表面归属,例如 `Shell / Integration`、`Main Flow / PMFlow OS`、`Space`、`Account Settings`。`Packet` 仍是实际执行单位;不能只填写模块名后直接开工。
## Meta ## Meta
- Packet ID: `{{PACKET_ID}}` - Packet ID: `{{PACKET_ID}}`
- Module Lane: `Shell / Integration | Main Flow / PMFlow OS | Space | Account Settings | Other`
- Phase: `{{PHASE}}` - Phase: `{{PHASE}}`
- Owner: `{{OWNER}}` - Owner: `{{OWNER}}`
- Lane Owner: `{{LANE_OWNER}}`
- Integration Owner: `{{INTEGRATION_OWNER}}`
- Generated At: `{{GENERATED_AT}}` - Generated At: `{{GENERATED_AT}}`
- Base Ref: `{{BASE_REF}}` - Base Ref: `{{BASE_REF}}`
- Head Ref: `{{HEAD_REF}}` - Head Ref: `{{HEAD_REF}}`
@ -28,6 +33,23 @@
{{TARGET_OBJECT_OR_CONTRACT}} {{TARGET_OBJECT_OR_CONTRACT}}
## Module Boundary
- Lane Responsibility:
{{LANE_RESPONSIBILITY}}
- Cross-lane Inputs:
{{CROSS_LANE_INPUTS}}
- Cross-lane Outputs:
{{CROSS_LANE_OUTPUTS}}
- Shared Shell / Route / Navigation Impact:
{{SHELL_ROUTE_NAV_IMPACT}}
- If shared files are needed, owner approval / dependency:
{{SHARED_OWNER_APPROVAL_OR_DEPENDENCY}}
## Scope ## Scope
- Read Scope: - Read Scope:
@ -76,6 +98,7 @@
## Integration Notes ## Integration Notes
- Integration Owner: `{{INTEGRATION_OWNER}}` - Integration Owner: `{{INTEGRATION_OWNER}}`
- Module Lane Merge Group: `{{MODULE_LANE_MERGE_GROUP}}`
- Merge Order: `{{MERGE_ORDER}}` - Merge Order: `{{MERGE_ORDER}}`
- Expected Commit(s): `{{EXPECTED_COMMITS}}` - Expected Commit(s): `{{EXPECTED_COMMITS}}`
- Out-of-scope Findings: - Out-of-scope Findings:
@ -86,6 +109,9 @@
- [ ] `base_ref` recorded - [ ] `base_ref` recorded
- [ ] `read_scope` and `write_scope` declared - [ ] `read_scope` and `write_scope` declared
- [ ] `forbidden_paths` checked - [ ] `forbidden_paths` checked
- [ ] Module lane declared
- [ ] Cross-lane inputs/outputs declared
- [ ] Shared shell/router/nav impact declared
- [ ] Shared files reserved or assigned to integration owner - [ ] Shared files reserved or assigned to integration owner
- [ ] Code completed - [ ] Code completed
- [ ] Contracts synchronized - [ ] Contracts synchronized

View File

@ -0,0 +1,148 @@
export type AccountLanguage = "zh-CN" | "en-US";
export type AccountAnswerDepth = "concise" | "balanced" | "deep";
export type AccountEvidenceDisplay = "summary" | "inline" | "full";
export type AccountNotificationKey = "pendingConfirmation" | "executionWriteback" | "releaseGate" | "failureAlert";
export type AccountSettingsSaveScope = "local" | "account";
export interface AccountProfileSettings {
displayName: string;
roleTitle: string;
email: string;
defaultLanguage: AccountLanguage;
}
export interface AccountAiPreferenceSettings {
answerDepth: AccountAnswerDepth;
evidenceDisplay: AccountEvidenceDisplay;
}
export interface AccountNotificationPreference {
enabled: boolean;
desktop: boolean;
emailDigest: boolean;
}
export type AccountNotificationSettings = Record<AccountNotificationKey, AccountNotificationPreference>;
export interface AccountSettings {
profile: AccountProfileSettings;
aiPreferences: AccountAiPreferenceSettings;
notifications: AccountNotificationSettings;
saveScope: AccountSettingsSaveScope;
updatedAt: string;
}
export interface AccountSettingsSeed {
displayName?: string | null;
roleTitle?: string | null;
email?: string | null;
}
const STORAGE_KEY = "pmflow:account-settings:v1";
export const accountNotificationLabels: Record<AccountNotificationKey, { title: string; detail: string }> = {
pendingConfirmation: {
title: "待确认",
detail: "AI 生成草案、变更项或写入前确认需要你拍板时提醒。",
},
executionWriteback: {
title: "执行回写",
detail: "Agent、IDE 或集成系统回传开发进度、分支、PR 与测试结果时提醒。",
},
releaseGate: {
title: "发布门禁",
detail: "发布校验、合规检查、验收项阻塞或通过时提醒。",
},
failureAlert: {
title: "失败提醒",
detail: "AI 调用、同步任务、执行回写或外部集成失败时提醒。",
},
};
export const accountNotificationKeys: AccountNotificationKey[] = [
"pendingConfirmation",
"executionWriteback",
"releaseGate",
"failureAlert",
];
export function createDefaultAccountSettings(seed: AccountSettingsSeed = {}): AccountSettings {
return {
profile: {
displayName: seed.displayName?.trim() || "张子墨",
roleTitle: seed.roleTitle?.trim() || "产品负责人",
email: seed.email?.trim() || "",
defaultLanguage: "zh-CN",
},
aiPreferences: {
answerDepth: "balanced",
evidenceDisplay: "inline",
},
notifications: {
pendingConfirmation: { enabled: true, desktop: true, emailDigest: false },
executionWriteback: { enabled: true, desktop: true, emailDigest: false },
releaseGate: { enabled: true, desktop: true, emailDigest: true },
failureAlert: { enabled: true, desktop: true, emailDigest: true },
},
saveScope: "local",
updatedAt: new Date().toISOString(),
};
}
function isBrowserStorageAvailable(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readStoredAccountSettings(): Partial<AccountSettings> | null {
if (!isBrowserStorageAvailable()) return null;
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<AccountSettings>;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function persistAccountSettings(settings: AccountSettings): void {
if (!isBrowserStorageAvailable()) {
throw new Error("当前环境不支持本机暂存");
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings {
if (!stored) return base;
return {
...base,
...stored,
profile: {
...base.profile,
...stored.profile,
},
aiPreferences: {
...base.aiPreferences,
...stored.aiPreferences,
},
notifications: {
...base.notifications,
...stored.notifications,
},
};
}
export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> {
const defaults = createDefaultAccountSettings(seed);
return mergeAccountSettings(defaults, readStoredAccountSettings());
}
export async function updateAccountSettings(nextSettings: AccountSettings): Promise<AccountSettings> {
const persisted = {
...nextSettings,
saveScope: "local" as const,
updatedAt: new Date().toISOString(),
};
persistAccountSettings(persisted);
return persisted;
}

391
frontend/src/api/space.ts Normal file
View File

@ -0,0 +1,391 @@
import { getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowExecutionSummary } from "./pmflowOs";
import { projectsApi, type ProjectSummary } from "./projects";
import type {
ChangeItem,
PMFlowExecutionStatus,
PMFlowObjectStatus,
ProjectTruthState,
} from "@/features/pmflow-os/types";
export interface SpaceWorkbenchProject {
id: string;
title: string;
summary: string;
status: string;
stage: string;
progress: number;
pendingCount: number;
activeTaskCount: number;
riskLabel: string;
nextAction: string;
updatedAt: string;
href: string;
}
export interface SpacePendingConfirmation {
id: string;
projectId: string;
projectTitle: string;
title: string;
summary: string;
source: "changeset" | "approval";
stage: string;
riskLevel: "low" | "medium" | "high";
createdAt: string;
href: string;
}
export interface SpaceAIProgressItem {
id: string;
projectId: string;
projectTitle: string;
title: string;
summary: string;
status: PMFlowExecutionStatus | "active" | "synced";
progress: number;
updatedAt: string;
href: string;
}
export interface SpaceDraftItem {
id: string;
projectId: string;
projectTitle: string;
title: string;
summary: string;
objectType: string;
status: PMFlowObjectStatus;
updatedAt: string;
href: string;
}
export interface SpaceSavedIdea {
id: string;
title: string;
summary: string;
updatedAt: string;
href: string;
}
export interface SpaceWorkbench {
projects: SpaceWorkbenchProject[];
pendingConfirmations: SpacePendingConfirmation[];
aiProgress: SpaceAIProgressItem[];
savedIdeas: SpaceSavedIdea[];
drafts: SpaceDraftItem[];
recentProjects: SpaceWorkbenchProject[];
warnings: string[];
generatedAt: string;
}
interface ProjectSnapshot {
project: ProjectSummary;
truth: ProjectTruthState | null;
execution: PMFlowExecutionSummary | null;
warnings: string[];
}
const ACTIVE_EXECUTION_STATUSES: Array<PMFlowExecutionStatus> = ["queued", "pushed", "coding", "pr_open", "testing"];
function stageLabel(stage?: string | null): string {
const labels: Record<string, string> = {
idea: "想法",
validation: "验证",
planning: "规划",
development: "开发",
testing: "测试",
release: "发布",
growth: "增长",
};
return labels[String(stage || "")] || "推进中";
}
function statusLabel(status?: string | null): string {
const labels: Record<string, string> = {
active: "进行中",
completed: "已完成",
archived: "已归档",
on_hold: "暂停",
draft: "草案",
confirmed: "已确认",
in_progress: "进行中",
blocked: "阻塞",
done: "完成",
released: "已发布",
};
return labels[String(status || "")] || "未同步";
}
function riskLabel(truth: ProjectTruthState | null): string {
if (!truth) return "未同步";
const activeRisks = truth.risks.filter((risk) => risk.status !== "done" && risk.status !== "released");
if (activeRisks.some((risk) => risk.level === "high")) return "高";
if (activeRisks.some((risk) => risk.level === "medium")) return "中";
if (activeRisks.length > 0) return "低";
return "无";
}
function objectTitle(value: unknown, fallback: string): string {
if (!value || typeof value !== "object") return fallback;
const record = value as Record<string, unknown>;
return String(record.title || record.name || record.headline || fallback);
}
function objectSummary(value: unknown, fallback = "等待在主流程确认后写回事实层。"): string {
if (!value || typeof value !== "object") return fallback;
const record = value as Record<string, unknown>;
return String(record.summary || record.diff || record.description || fallback);
}
function objectCreatedAt(value: unknown, fallback: string): string {
if (!value || typeof value !== "object") return fallback;
const record = value as Record<string, unknown>;
return String(record.createdAt || record.created_at || record.updatedAt || record.updated_at || fallback);
}
function objectId(value: unknown, fallback: string): string {
if (!value || typeof value !== "object") return fallback;
const record = value as Record<string, unknown>;
return String(record.id || fallback);
}
function objectRiskLevel(value: unknown): "low" | "medium" | "high" {
if (!value || typeof value !== "object") return "medium";
const raw = String((value as Record<string, unknown>).riskLevel || (value as Record<string, unknown>).risk_level || "");
return raw === "high" || raw === "low" ? raw : "medium";
}
function reviewHref(projectId: string, changeSetId?: string | null): string {
const params = new URLSearchParams({ projectId });
if (changeSetId) params.set("changeSetId", changeSetId);
return `/change-review?${params.toString()}`;
}
function projectHref(projectId: string): string {
return `/pmflow-os?projectId=${encodeURIComponent(projectId)}`;
}
function pendingChangeItems(truth: ProjectTruthState | null): ChangeItem[] {
if (!truth) return [];
return truth.changeItems.filter((item) => item.status === "pending_review");
}
function deriveProject(snapshot: ProjectSnapshot): SpaceWorkbenchProject {
const { project, truth, execution } = snapshot;
const pending = pendingChangeItems(truth);
const inProgressTasks = truth?.tasks.filter((task) => task.status === "in_progress") ?? [];
const blockedTasks = truth?.tasks.filter((task) => task.status === "blocked") ?? [];
const nextPending = pending[0];
const nextTask = inProgressTasks[0] || blockedTasks[0] || truth?.tasks.find((task) => task.status === "draft");
const currentStage = truth?.project.stage || (nextTask ? nextTask.stage : null);
const nextAction = nextPending?.title || nextTask?.title || "进入项目主流程继续推进";
return {
id: String(project.id),
title: project.title || truth?.project.name || `项目 ${project.id}`,
summary: project.description || truth?.project.goal || "暂无项目摘要",
status: statusLabel(project.status),
stage: stageLabel(currentStage),
progress: Math.max(0, Math.min(100, Math.round(project.progress ?? 0))),
pendingCount: pending.length + (truth?.approvals.length ?? 0),
activeTaskCount: inProgressTasks.length + blockedTasks.length + (execution?.activeDevTasks.length ?? 0),
riskLabel: riskLabel(truth),
nextAction,
updatedAt: project.updated_at || project.created_at,
href: projectHref(String(project.id)),
};
}
function derivePending(snapshot: ProjectSnapshot): SpacePendingConfirmation[] {
const { project, truth } = snapshot;
if (!truth) return [];
const projectId = String(project.id);
const projectTitle = project.title || truth.project.name || `项目 ${projectId}`;
const changeItems = pendingChangeItems(truth).map((item) => ({
id: `change-${item.id}`,
projectId,
projectTitle,
title: item.title,
summary: item.summary || item.diff || "等待在主流程确认变更。",
source: "changeset" as const,
stage: stageLabel(item.objectType),
riskLevel: item.riskLevel,
createdAt: objectCreatedAt(
truth.changeSets.find((changeSet) => objectId(changeSet, "") === item.changeSetId),
truth.lastWritebackAt || project.updated_at
),
href: reviewHref(projectId, item.changeSetId),
}));
const approvals = truth.approvals.map((approval, index) => ({
id: `approval-${objectId(approval, `${projectId}-${index}`)}`,
projectId,
projectTitle,
title: objectTitle(approval, "待确认审批"),
summary: objectSummary(approval, "审批项需要回到项目主流程处理。"),
source: "approval" as const,
stage: stageLabel(approval.stage),
riskLevel: objectRiskLevel(approval),
createdAt: objectCreatedAt(approval, project.updated_at),
href: reviewHref(projectId, truth.activeChangeSetId),
}));
return [...changeItems, ...approvals];
}
function progressFromExecutionStatus(status: PMFlowExecutionStatus | "active" | "synced"): number {
const values: Record<string, number> = {
queued: 12,
pushed: 28,
coding: 52,
pr_open: 72,
testing: 84,
synced: 100,
failed: 100,
active: 48,
};
return values[status] ?? 40;
}
function deriveAIProgress(snapshot: ProjectSnapshot): SpaceAIProgressItem[] {
const { project, truth, execution } = snapshot;
const projectId = String(project.id);
const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`;
const href = projectHref(projectId);
const items: SpaceAIProgressItem[] = [];
if (execution && ACTIVE_EXECUTION_STATUSES.includes(execution.execution.status)) {
const status = execution.execution.status;
items.push({
id: `execution-${projectId}`,
projectId,
projectTitle,
title: execution.execution.taskId ? `执行任务 ${execution.execution.taskId}` : "AI 正在推进执行链路",
summary: execution.execution.prompt || execution.execution.testSummary || "正在聚合执行进度与回写状态。",
status,
progress: progressFromExecutionStatus(status),
updatedAt: project.updated_at,
href,
});
}
for (const [index, event] of (execution?.recentProgressEvents ?? []).slice(0, 2).entries()) {
items.push({
id: `progress-${projectId}-${objectId(event, String(index))}`,
projectId,
projectTitle,
title: objectTitle(event, "执行进度更新"),
summary: objectSummary(event, "AI 执行进度已更新。"),
status: "active",
progress: 64,
updatedAt: objectCreatedAt(event, project.updated_at),
href,
});
}
for (const [index, task] of (execution?.activeDevTasks ?? []).slice(0, 2).entries()) {
items.push({
id: `dev-task-${projectId}-${objectId(task, String(index))}`,
projectId,
projectTitle,
title: objectTitle(task, "开发任务进行中"),
summary: objectSummary(task, "开发 Agent 正在推进该任务。"),
status: "active",
progress: 58,
updatedAt: objectCreatedAt(task, project.updated_at),
href,
});
}
if (truth?.loopStage === "ai_extracting") {
items.push({
id: `extracting-${projectId}`,
projectId,
projectTitle,
title: "AI 正在抽取项目事实",
summary: "正在从会话与材料中生成待确认变更。",
status: "active",
progress: 42,
updatedAt: project.updated_at,
href,
});
}
return items;
}
function deriveDrafts(snapshot: ProjectSnapshot): SpaceDraftItem[] {
const { project, truth } = snapshot;
if (!truth) return [];
const projectId = String(project.id);
const projectTitle = project.title || truth.project.name || `项目 ${projectId}`;
const href = projectHref(projectId);
const draftObjects = [
...truth.requirements.map((item) => ({ ...item, objectType: "需求" })),
...truth.decisions.map((item) => ({ ...item, objectType: "决策" })),
...truth.tasks.map((item) => ({ ...item, objectType: "任务" })),
...truth.risks.map((item) => ({ ...item, objectType: "风险" })),
...truth.modelDecisions.map((item) => ({ ...item, objectType: "模型决策" })),
].filter((item) => item.status === "draft");
return draftObjects.map((item) => ({
id: `${projectId}-${item.objectType}-${item.id}`,
projectId,
projectTitle,
title: item.title,
summary: item.summary,
objectType: item.objectType,
status: item.status,
updatedAt: project.updated_at,
href,
}));
}
function dateValue(item: { updatedAt?: string; updated_at?: string; createdAt?: string; created_at?: string }): number {
return new Date(item.updatedAt || item.updated_at || item.createdAt || item.created_at || 0).getTime();
}
function sortByDate<T extends { updatedAt?: string; updated_at?: string; createdAt?: string; created_at?: string }>(
items: T[]
): T[] {
return [...items].sort((a, b) => dateValue(b) - dateValue(a));
}
async function getProjectSnapshot(project: ProjectSummary): Promise<ProjectSnapshot> {
const projectId = String(project.id);
const [truthResult, executionResult] = await Promise.allSettled([
getPMFlowTruthState(projectId),
getPMFlowExecutionSummary(projectId),
]);
const warnings: string[] = [];
const truth = truthResult.status === "fulfilled" ? truthResult.value : null;
const execution = executionResult.status === "fulfilled" ? executionResult.value : null;
if (truthResult.status === "rejected") warnings.push(`${project.title}: 项目真相摘要未同步`);
if (executionResult.status === "rejected") warnings.push(`${project.title}: 执行摘要未同步`);
const projectionWarnings = truth
? (truth as unknown as { projectionWarnings?: string[] }).projectionWarnings ?? []
: [];
if (projectionWarnings.length) warnings.push(...projectionWarnings.map((item) => `${project.title}: ${item}`));
if (execution?.warnings.length) warnings.push(...execution.warnings.map((item) => `${project.title}: ${item}`));
return { project, truth, execution, warnings };
}
export const spaceApi = {
async getWorkbench(): Promise<SpaceWorkbench> {
const summary = await projectsApi.getSummary({ status: "active" });
const projects = sortByDate(summary.projects).slice(0, 8);
const snapshots = await Promise.all(projects.map(getProjectSnapshot));
const workbenchProjects = snapshots.map(deriveProject);
return {
projects: workbenchProjects.slice(0, 4),
pendingConfirmations: sortByDate(snapshots.flatMap(derivePending)).slice(0, 8),
aiProgress: sortByDate(snapshots.flatMap(deriveAIProgress)).slice(0, 8),
savedIdeas: [],
drafts: sortByDate(snapshots.flatMap(deriveDrafts)).slice(0, 8),
recentProjects: sortByDate(workbenchProjects),
warnings: [
"暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。",
...snapshots.flatMap((snapshot) => snapshot.warnings),
].slice(0, 8),
generatedAt: new Date().toISOString(),
};
},
};

View File

@ -0,0 +1,768 @@
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Bell,
Bot,
Check,
ChevronDown,
LogOut,
Mail,
Repeat2,
ShieldCheck,
SlidersHorizontal,
UserRound,
} from "lucide-react";
import { Link } from "react-router-dom";
import {
accountNotificationKeys,
accountNotificationLabels,
getAccountSettings,
updateAccountSettings,
type AccountAiPreferenceSettings,
type AccountAnswerDepth,
type AccountEvidenceDisplay,
type AccountLanguage,
type AccountNotificationKey,
type AccountProfileSettings,
type AccountSettings,
type AccountSettingsSaveScope,
} from "@/api/accountSettings";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { MobileBottomSheet } from "@/features/pmflow-os/components/FeedbackPrimitives";
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth";
type MobileSheetKey = "profile" | "ai" | "notifications" | null;
const answerDepthOptions: Array<{ value: AccountAnswerDepth; title: string; detail: string }> = [
{ value: "concise", title: "简洁", detail: "只给结论和必要依据" },
{ value: "balanced", title: "标准", detail: "结论、依据、下一步都保留" },
{ value: "deep", title: "深入", detail: "展开证据链、风险和取舍" },
];
const evidenceOptions: Array<{ value: AccountEvidenceDisplay; title: string; detail: string }> = [
{ value: "summary", title: "摘要", detail: "只展示来源摘要" },
{ value: "inline", title: "内联", detail: "关键结论旁展示证据" },
{ value: "full", title: "完整", detail: "保留来源、片段和验证状态" },
];
const languageOptions: Array<{ value: AccountLanguage; title: string; detail: string }> = [
{ value: "zh-CN", title: "中文", detail: "界面、AI 回复和通知默认中文" },
{ value: "en-US", title: "English", detail: "English replies when product context allows it" },
];
function getDisplayName(user: ReturnType<typeof useAuthStore.getState>["user"]) {
return user?.name || user?.username || "张子墨";
}
function getDisplayRole(user: ReturnType<typeof useAuthStore.getState>["user"]) {
if (user?.role === "admin") return "管理员";
if (user?.role === "manager") return "项目负责人";
return user?.role || "产品负责人";
}
function getAvatarSeed(user: ReturnType<typeof useAuthStore.getState>["user"], displayName: string) {
return encodeURIComponent(user?.id || user?.username || displayName || "pmflow-account");
}
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "尚未保存";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
export function AccountSettingsPanel({ onLogout }: { onLogout: () => void }) {
const queryClient = useQueryClient();
const user = useAuthStore((state) => state.user);
const seed = useMemo(
() => ({
displayName: getDisplayName(user),
roleTitle: getDisplayRole(user),
email: user?.email || "",
}),
[user]
);
const settingsQuery = useQuery({
queryKey: ["account-settings", seed.displayName, seed.roleTitle, seed.email],
queryFn: () => getAccountSettings(seed),
});
const [draft, setDraft] = useState<AccountSettings | null>(null);
const [activeSheet, setActiveSheet] = useState<MobileSheetKey>(null);
useEffect(() => {
if (settingsQuery.data) setDraft(settingsQuery.data);
}, [settingsQuery.data]);
const saveMutation = useMutation({
mutationFn: updateAccountSettings,
onSuccess: (saved) => {
setDraft(saved);
queryClient.setQueryData(["account-settings", seed.displayName, seed.roleTitle, seed.email], saved);
},
});
const isDirty = Boolean(
draft && settingsQuery.data && JSON.stringify(draft) !== JSON.stringify(settingsQuery.data)
);
const avatarSrc = `https://api.dicebear.com/7.x/avataaars/svg?seed=${getAvatarSeed(user, draft?.profile.displayName || seed.displayName)}`;
const patchProfile = (patch: Partial<AccountProfileSettings>) => {
setDraft((current) => current ? { ...current, profile: { ...current.profile, ...patch } } : current);
};
const patchAi = (patch: Partial<AccountAiPreferenceSettings>) => {
setDraft((current) => current ? { ...current, aiPreferences: { ...current.aiPreferences, ...patch } } : current);
};
const patchNotification = (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => {
setDraft((current) =>
current
? {
...current,
notifications: {
...current.notifications,
[key]: { ...current.notifications[key], ...patch },
},
}
: current
);
};
const save = () => {
if (!draft) return;
saveMutation.mutate(draft);
};
const saveStatus: SaveStatus = saveMutation.isError
? "failed"
: draft?.saveScope === "account"
? "account"
: "local";
if (settingsQuery.isLoading || !draft) {
return <AccountSettingsSkeleton />;
}
return (
<>
<DesktopAccountSettings
avatarSrc={avatarSrc}
draft={draft}
isDirty={isDirty}
isSaving={saveMutation.isPending}
saveStatus={saveStatus}
onLogout={onLogout}
onPatchAi={patchAi}
onPatchNotification={patchNotification}
onPatchProfile={patchProfile}
onSave={save}
/>
<MobileAccountSettings
avatarSrc={avatarSrc}
draft={draft}
activeSheet={activeSheet}
isDirty={isDirty}
isSaving={saveMutation.isPending}
saveStatus={saveStatus}
onOpenSheet={setActiveSheet}
onLogout={onLogout}
onPatchAi={patchAi}
onPatchNotification={patchNotification}
onPatchProfile={patchProfile}
onSave={save}
/>
</>
);
}
function DesktopAccountSettings({
avatarSrc,
draft,
isDirty,
isSaving,
saveStatus,
onLogout,
onPatchAi,
onPatchNotification,
onPatchProfile,
onSave,
}: {
avatarSrc: string;
draft: AccountSettings;
isDirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
onLogout: () => void;
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
onSave: () => void;
}) {
return (
<div className="hidden min-h-full bg-white md:block">
<div className="mx-auto w-full max-w-[1180px] px-8 py-8 xl:max-w-[1320px]">
<div className="mb-7 flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#2463EB]">我的</p>
<h1 className="mt-2 text-2xl font-semibold text-slate-950">个人资料与偏好</h1>
<p className="mt-2 text-sm text-slate-500">这些设置会影响 PMFlow 的默认语言、AI 回复形态、证据展示和提醒节奏。</p>
</div>
<div className="flex items-center gap-3">
<Link to="/space" className="rounded-lg border border-[#dfe7f3] px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-[#2463EB] hover:text-[#2463EB] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30">
返回个人空间
</Link>
<Button
className="h-10 rounded-lg bg-[#2463EB] px-5 font-semibold text-white hover:bg-[#1f58d5]"
disabled={!isDirty || isSaving}
onClick={onSave}
>
{getSaveButtonLabel({ isDirty, isSaving, saveStatus })}
</Button>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-[340px_minmax(0,1fr)]">
<aside className="space-y-5">
<ProfileSummary avatarSrc={avatarSrc} draft={draft} />
<SaveStatusPanel saveStatus={saveStatus} />
<section className="rounded-lg border border-[#e6edf7] bg-[#f8fbff] p-5">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-[#2463EB]">
<ShieldCheck className="h-5 w-5" />
</span>
<div>
<h2 className="text-base font-semibold text-slate-950">账户操作边界</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">本页不修改 OAuth、JWT 或权限核心。退出登录只清理当前浏览器会话,草案、待确认和项目记忆仍按原链路保留。</p>
</div>
</div>
<div className="mt-5 grid gap-2">
<ActionButton icon={<Repeat2 className="h-4 w-4" />} label="切换账号" detail="保留当前空间上下文" />
<ActionButton icon={<LogOut className="h-4 w-4" />} label="退出登录" detail="AI 进度与待确认项保留" danger onClick={onLogout} />
</div>
</section>
</aside>
<main className="space-y-5">
<SettingsSection icon={<UserRound className="h-5 w-5" />} title="个人资料" detail="用于 PMFlow 内的称呼、空间标识和通知展示。">
<ProfileFields profile={draft.profile} onPatchProfile={onPatchProfile} />
</SettingsSection>
<SettingsSection icon={<Bot className="h-5 w-5" />} title="AI 偏好" detail="控制默认回答深度和证据呈现,不影响运行时模型路由。">
<AiPreferenceFields aiPreferences={draft.aiPreferences} onPatchAi={onPatchAi} />
</SettingsSection>
<SettingsSection icon={<Bell className="h-5 w-5" />} title="通知偏好" detail="优先覆盖需要人类拍板、执行回写、发布门禁和失败提醒。">
<NotificationFields notifications={draft.notifications} onPatchNotification={onPatchNotification} />
</SettingsSection>
</main>
</div>
</div>
</div>
);
}
function MobileAccountSettings({
avatarSrc,
draft,
activeSheet,
isDirty,
isSaving,
saveStatus,
onOpenSheet,
onLogout,
onPatchAi,
onPatchNotification,
onPatchProfile,
onSave,
}: {
avatarSrc: string;
draft: AccountSettings;
activeSheet: MobileSheetKey;
isDirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
onOpenSheet: (key: MobileSheetKey) => void;
onLogout: () => void;
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
onSave: () => void;
}) {
return (
<div className="min-h-screen bg-white px-4 pb-32 pt-5 text-slate-950 md:hidden">
<div className="flex items-center justify-between">
<PMFlowBrand compact />
<button type="button" className="rounded-lg border border-[#e6edf7] px-3 py-2 text-sm font-semibold text-slate-700">
个人空间 <ChevronDown className="ml-1 inline h-3.5 w-3.5" />
</button>
</div>
<div className="mt-7">
<ProfileSummary avatarSrc={avatarSrc} draft={draft} mobile />
</div>
<div className="mt-4">
<SaveStatusPanel saveStatus={saveStatus} mobile />
</div>
<MobileGroup title="设置">
<MobileSettingRow icon={<UserRound className="h-5 w-5" />} title="个人资料" detail={`${draft.profile.displayName} · ${draft.profile.roleTitle}`} onClick={() => onOpenSheet("profile")} />
<MobileSettingRow icon={<SlidersHorizontal className="h-5 w-5" />} title="AI 偏好" detail={`${getAnswerDepthTitle(draft.aiPreferences.answerDepth)} · ${getEvidenceTitle(draft.aiPreferences.evidenceDisplay)}`} onClick={() => onOpenSheet("ai")} />
<MobileSettingRow icon={<Bell className="h-5 w-5" />} title="通知偏好" detail={`${getEnabledNotificationCount(draft)} 类提醒已开启`} onClick={() => onOpenSheet("notifications")} />
</MobileGroup>
<MobileGroup title="账号操作">
<MobileSettingRow icon={<Repeat2 className="h-5 w-5" />} title="切换账号" detail="保留当前空间上下文" />
<MobileSettingRow icon={<LogOut className="h-5 w-5" />} title="退出登录" detail="草案与待确认事项保留" danger onClick={onLogout} />
</MobileGroup>
<div className="fixed inset-x-0 bottom-[68px] z-40 border-t border-[#e6edf7] bg-white/95 px-4 py-3 backdrop-blur md:hidden">
<Button
className="h-12 w-full rounded-lg bg-[#2463EB] text-base font-semibold text-white hover:bg-[#1f58d5]"
disabled={!isDirty || isSaving}
onClick={onSave}
>
{getSaveButtonLabel({ isDirty, isSaving, saveStatus })}
</Button>
</div>
<MobileBottomNav />
<MobileBottomSheet
open={activeSheet === "profile"}
title="个人资料"
detail="用于空间标识、通知展示和 AI 对你的默认称呼。"
onClose={() => onOpenSheet(null)}
>
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
<ProfileFields profile={draft.profile} onPatchProfile={onPatchProfile} mobile />
</div>
</MobileBottomSheet>
<MobileBottomSheet
open={activeSheet === "ai"}
title="AI 偏好"
detail="偏好只影响 PMFlow 的默认回复形态,不改变模型路由和权限。"
onClose={() => onOpenSheet(null)}
>
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
<AiPreferenceFields aiPreferences={draft.aiPreferences} onPatchAi={onPatchAi} mobile />
</div>
</MobileBottomSheet>
<MobileBottomSheet
open={activeSheet === "notifications"}
title="通知偏好"
detail="优先处理需要你拍板、发布受阻或执行链路异常的提醒。"
onClose={() => onOpenSheet(null)}
>
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
<NotificationFields notifications={draft.notifications} onPatchNotification={onPatchNotification} mobile />
</div>
</MobileBottomSheet>
</div>
);
}
function ProfileSummary({ avatarSrc, draft, mobile = false }: { avatarSrc: string; draft: AccountSettings; mobile?: boolean }) {
return (
<section className={cn("rounded-lg border border-[#e6edf7] bg-white p-5", !mobile && "shadow-[0_12px_34px_rgba(15,23,42,0.04)]")}>
<div className="flex items-center gap-4">
<img src={avatarSrc} alt={`${draft.profile.displayName}头像`} className="h-16 w-16 rounded-full bg-[#eef4ff]" />
<div className="min-w-0">
<div className="truncate text-xl font-semibold text-slate-950">{draft.profile.displayName}</div>
<div className="mt-1 truncate text-sm text-slate-500">{draft.profile.roleTitle} · 个人空间</div>
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-[#eef4ff] px-3 py-1 text-xs font-semibold text-[#2463EB]">
<Check className="h-3.5 w-3.5" />
{draft.profile.defaultLanguage === "zh-CN" ? "默认中文" : "Default English"}
</div>
</div>
</div>
<div className="mt-5 border-t border-[#eef2f8] pt-4 text-xs text-slate-500">上次保存:{formatUpdatedAt(draft.updatedAt)}</div>
</section>
);
}
function SettingsSection({ icon, title, detail, children }: { icon: ReactNode; title: string; detail: string; children: ReactNode }) {
return (
<section className="rounded-lg border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
<div className="mb-5 flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#eef4ff] text-[#2463EB]">{icon}</span>
<div>
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
<p className="mt-1 text-sm leading-6 text-slate-500">{detail}</p>
</div>
</div>
{children}
</section>
);
}
type SaveStatus = AccountSettingsSaveScope | "failed";
function getSaveStatusCopy(saveStatus: SaveStatus): {
title: string;
detail: string;
wrap: string;
iconWrap: string;
titleClass: string;
Icon: ComponentType<{ className?: string }>;
} {
if (saveStatus === "account") {
return {
title: "已保存到当前账号",
detail: "这些偏好已和当前登录身份绑定,后续可由系统能力消费。",
wrap: "border-[#cfe0ff] bg-[#eef4ff]",
iconWrap: "bg-white text-[#2463EB]",
titleClass: "text-[#2459d3]",
Icon: ShieldCheck,
};
}
if (saveStatus === "failed") {
return {
title: "保存失败",
detail: "当前偏好还没有写入本机暂存。请重试,或检查浏览器存储权限。",
wrap: "border-rose-100 bg-rose-50",
iconWrap: "bg-white text-rose-600",
titleClass: "text-rose-700",
Icon: AlertTriangle,
};
}
return {
title: "仅本机暂存",
detail: "当前 V1 不写云端账号设置;刷新本机浏览器会保留,换设备不会同步。",
wrap: "border-[#e6edf7] bg-[#f8fbff]",
iconWrap: "bg-white text-slate-500",
titleClass: "text-slate-700",
Icon: Check,
};
}
function getSaveButtonLabel({
isDirty,
isSaving,
saveStatus,
}: {
isDirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
}) {
if (isSaving) return "保存中";
if (isDirty && saveStatus === "failed") return "重试保存";
if (isDirty) return "保存设置";
if (saveStatus === "account") return "已保存到当前账号";
if (saveStatus === "failed") return "保存失败";
return "仅本机暂存";
}
function SaveStatusPanel({ saveStatus, mobile = false }: { saveStatus: SaveStatus; mobile?: boolean }) {
const status = getSaveStatusCopy(saveStatus);
return (
<section className={cn("rounded-lg border px-4 py-3 text-sm", status.wrap, !mobile && "shadow-[0_12px_34px_rgba(15,23,42,0.03)]")}>
<div className="flex items-start gap-3">
<span className={cn("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg", status.iconWrap)}>
<status.Icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className={cn("font-semibold", status.titleClass)}>{status.title}</div>
<div className="mt-1 leading-6 text-slate-500">{status.detail}</div>
</div>
</div>
</section>
);
}
function ProfileFields({
profile,
mobile = false,
onPatchProfile,
}: {
profile: AccountProfileSettings;
mobile?: boolean;
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
}) {
return (
<div className={cn("grid gap-4", !mobile && "sm:grid-cols-2")}>
<LabeledControl label="显示名称">
<Input
value={profile.displayName}
onChange={(event) => onPatchProfile({ displayName: event.target.value })}
className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30"
/>
</LabeledControl>
<LabeledControl label="角色称谓">
<Input
value={profile.roleTitle}
onChange={(event) => onPatchProfile({ roleTitle: event.target.value })}
className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30"
/>
</LabeledControl>
<LabeledControl label="邮箱">
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={profile.email}
onChange={(event) => onPatchProfile({ email: event.target.value })}
className="h-11 rounded-lg border-[#dfe7f3] pl-9 focus-visible:ring-[#2463EB]/30"
placeholder="用于通知摘要"
/>
</div>
</LabeledControl>
<LabeledControl label="默认语言">
<SegmentedOptions
options={languageOptions}
value={profile.defaultLanguage}
onChange={(value) => onPatchProfile({ defaultLanguage: value })}
/>
</LabeledControl>
</div>
);
}
function AiPreferenceFields({
aiPreferences,
mobile = false,
onPatchAi,
}: {
aiPreferences: AccountAiPreferenceSettings;
mobile?: boolean;
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
}) {
return (
<div className={cn("grid gap-5", !mobile && "xl:grid-cols-2")}>
<LabeledControl label="AI 回答深度">
<SegmentedOptions
options={answerDepthOptions}
value={aiPreferences.answerDepth}
onChange={(value) => onPatchAi({ answerDepth: value })}
/>
</LabeledControl>
<LabeledControl label="证据展示偏好">
<SegmentedOptions
options={evidenceOptions}
value={aiPreferences.evidenceDisplay}
onChange={(value) => onPatchAi({ evidenceDisplay: value })}
/>
</LabeledControl>
</div>
);
}
function NotificationFields({
notifications,
mobile = false,
onPatchNotification,
}: {
notifications: AccountSettings["notifications"];
mobile?: boolean;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
}) {
return (
<div className="space-y-3">
{accountNotificationKeys.map((key) => {
const item = notifications[key];
const label = accountNotificationLabels[key];
return (
<div key={key} className={cn("rounded-lg border border-[#e6edf7] bg-white p-4", mobile && "px-3")}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-base font-semibold text-slate-950">{label.title}</div>
<div className="mt-1 text-sm leading-6 text-slate-500">{label.detail}</div>
</div>
<Switch
checked={item.enabled}
onCheckedChange={(enabled) => onPatchNotification(key, { enabled })}
className="mt-1 data-[state=checked]:bg-[#2463EB]"
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
<ChannelToggle
label="桌面提醒"
checked={item.desktop}
disabled={!item.enabled}
onChange={(desktop) => onPatchNotification(key, { desktop })}
/>
<ChannelToggle
label="邮件摘要"
checked={item.emailDigest}
disabled={!item.enabled}
onChange={(emailDigest) => onPatchNotification(key, { emailDigest })}
/>
</div>
</div>
);
})}
</div>
);
}
function ChannelToggle({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className={cn("flex min-h-10 items-center justify-between rounded-lg border px-3 text-sm font-semibold", disabled ? "border-[#eef2f8] bg-slate-50 text-slate-400" : "border-[#e6edf7] bg-[#f8fbff] text-slate-700")}>
{label}
<Switch checked={checked} disabled={disabled} onCheckedChange={onChange} className="data-[state=checked]:bg-[#2463EB]" />
</label>
);
}
function SegmentedOptions<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; title: string; detail: string }>;
value: T;
onChange: (value: T) => void;
}) {
return (
<div className="grid gap-2">
{options.map((option) => {
const selected = value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"flex min-h-[58px] w-full items-center justify-between gap-3 rounded-lg border px-4 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30",
selected ? "border-[#2463EB] bg-[#eef4ff] text-[#174cba]" : "border-[#e6edf7] bg-white text-slate-700 hover:border-[#cfe0ff] hover:bg-[#f8fbff]"
)}
>
<span>
<span className="block text-sm font-semibold">{option.title}</span>
<span className={cn("mt-1 block text-xs leading-5", selected ? "text-[#2459d3]" : "text-slate-500")}>{option.detail}</span>
</span>
<span className={cn("flex h-5 w-5 shrink-0 items-center justify-center rounded-full border", selected ? "border-[#2463EB] bg-[#2463EB] text-white" : "border-[#d5deea] text-transparent")}>
<Check className="h-3.5 w-3.5" />
</span>
</button>
);
})}
</div>
);
}
function LabeledControl({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="block">
<span className="mb-2 block text-sm font-semibold text-slate-700">{label}</span>
{children}
</div>
);
}
function ActionButton({
icon,
label,
detail,
danger,
onClick,
}: {
icon: ReactNode;
label: string;
detail: string;
danger?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30",
danger ? "border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100" : "border-[#e6edf7] bg-white text-slate-800 hover:border-[#cfe0ff]"
)}
>
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", danger ? "bg-white text-rose-500" : "bg-[#eef4ff] text-[#2463EB]")}>{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold">{label}</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">{detail}</span>
</span>
</button>
);
}
function MobileGroup({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="mt-7">
<h2 className="mb-3 text-lg font-semibold text-slate-950">{title}</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
function MobileSettingRow({
icon,
title,
detail,
danger,
onClick,
}: {
icon: ReactNode;
title: string;
detail?: string;
danger?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex min-h-[64px] w-full items-center justify-between gap-3 rounded-lg border px-4 text-left transition active:scale-[0.99]",
danger ? "border-rose-200 bg-rose-50 text-rose-600" : "border-[#e6edf7] bg-white text-slate-900"
)}
>
<span className="flex min-w-0 items-center gap-3">
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-lg", danger ? "bg-white text-rose-500" : "bg-[#eef4ff] text-[#2463EB]")}>{icon}</span>
<span className="min-w-0">
<span className="block text-base font-semibold">{title}</span>
{detail ? <span className="mt-0.5 block truncate text-sm text-slate-500">{detail}</span> : null}
</span>
</span>
<span className={danger ? "text-rose-300" : "text-slate-300"}>›</span>
</button>
);
}
function AccountSettingsSkeleton() {
return (
<div className="min-h-screen bg-white px-5 py-8">
<div className="mx-auto max-w-[1180px] animate-pulse">
<div className="h-8 w-48 rounded-lg bg-slate-100" />
<div className="mt-4 h-4 w-80 max-w-full rounded bg-slate-100" />
<div className="mt-8 grid gap-5 lg:grid-cols-[340px_minmax(0,1fr)]">
<div className="h-56 rounded-lg bg-slate-100" />
<div className="space-y-5">
<div className="h-52 rounded-lg bg-slate-100" />
<div className="h-52 rounded-lg bg-slate-100" />
</div>
</div>
</div>
</div>
);
}
function getEnabledNotificationCount(settings: AccountSettings) {
return accountNotificationKeys.filter((key) => settings.notifications[key].enabled).length;
}
function getAnswerDepthTitle(value: AccountAnswerDepth) {
return answerDepthOptions.find((option) => option.value === value)?.title || "标准";
}
function getEvidenceTitle(value: AccountEvidenceDisplay) {
return evidenceOptions.find((option) => option.value === value)?.title || "内联";
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { createDefaultAccountSettings, getAccountSettings, updateAccountSettings } from "@/api/accountSettings";
describe("account settings api", () => {
const storageSetItem = Storage.prototype.setItem;
beforeEach(() => {
window.localStorage.clear();
Storage.prototype.setItem = storageSetItem;
});
afterEach(() => {
Storage.prototype.setItem = storageSetItem;
vi.restoreAllMocks();
});
it("creates defaults from the current account seed", async () => {
const settings = await getAccountSettings({
displayName: "陈雨",
roleTitle: "独立开发者",
email: "yu@example.com",
});
expect(settings.profile.displayName).toBe("陈雨");
expect(settings.profile.roleTitle).toBe("独立开发者");
expect(settings.profile.email).toBe("yu@example.com");
expect(settings.saveScope).toBe("local");
expect(settings.aiPreferences.answerDepth).toBe("balanced");
expect(settings.notifications.pendingConfirmation.enabled).toBe(true);
expect(settings.notifications.executionWriteback.enabled).toBe(true);
expect(settings.notifications.releaseGate.enabled).toBe(true);
expect(settings.notifications.failureAlert.enabled).toBe(true);
});
it("persists updated preferences through the api layer", async () => {
const defaults = createDefaultAccountSettings({ displayName: "陈雨" });
const saved = await updateAccountSettings({
...defaults,
profile: { ...defaults.profile, defaultLanguage: "en-US" },
aiPreferences: { answerDepth: "deep", evidenceDisplay: "full" },
notifications: {
...defaults.notifications,
failureAlert: { enabled: false, desktop: false, emailDigest: false },
},
});
const restored = await getAccountSettings({ displayName: "陈雨" });
expect(saved.updatedAt).toBeTruthy();
expect(saved.saveScope).toBe("local");
expect(restored.profile.defaultLanguage).toBe("en-US");
expect(restored.aiPreferences.answerDepth).toBe("deep");
expect(restored.aiPreferences.evidenceDisplay).toBe("full");
expect(restored.notifications.failureAlert.enabled).toBe(false);
});
it("fails explicitly when local persistence is unavailable", async () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage denied");
});
await expect(updateAccountSettings(createDefaultAccountSettings())).rejects.toThrow("storage denied");
});
});

View File

@ -0,0 +1,50 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PersonalSpacePage from "./PersonalSpacePage";
import { spaceApi, type SpaceWorkbench } from "@/api/space";
import { renderWithProviders } from "@/test/renderWithProviders";
vi.mock("@/api/space", () => ({
spaceApi: {
getWorkbench: vi.fn(),
},
}));
vi.mock("@/features/pmflow-os/components/MobileBottomNav", () => ({
MobileBottomNav: () => <nav aria-label="mobile bottom nav" />,
}));
vi.mock("@/features/pmflow-os/components/AccountControls", () => ({
PMFlowAccountActions: () => <div data-testid="account-actions" />,
}));
const emptyWorkbench: SpaceWorkbench = {
projects: [],
pendingConfirmations: [],
aiProgress: [],
savedIdeas: [],
drafts: [],
recentProjects: [],
warnings: ["暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。"],
generatedAt: "2026-04-26T08:00:00.000Z",
};
describe("PersonalSpacePage", () => {
it("renders the read-only Space workbench sections without demo saved ideas", async () => {
vi.mocked(spaceApi.getWorkbench).mockResolvedValue(emptyWorkbench);
renderWithProviders(<PersonalSpacePage />, ["/space"]);
expect(await screen.findAllByText("暂无可继续推进的项目")).not.toHaveLength(0);
expect(screen.getAllByText("个人工作面")).not.toHaveLength(0);
expect(screen.getAllByText("继续推进")).not.toHaveLength(0);
expect(screen.getAllByText("待我确认")).not.toHaveLength(0);
expect(screen.getAllByText("AI 正在推进")).not.toHaveLength(0);
expect(screen.getAllByText("暂存想法")).not.toHaveLength(0);
expect(screen.getAllByText("草案箱")).not.toHaveLength(0);
expect(screen.getAllByText("最近项目")).not.toHaveLength(0);
expect(screen.getAllByText("暂无暂存想法")).not.toHaveLength(0);
expect(screen.getByText("暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。")).toBeInTheDocument();
expect(screen.queryByText("AI 周报助手")).not.toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -21,12 +21,12 @@ import { PMFlowBrandHeader } from "@/features/pmflow-os/components/chrome";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const sidebarMain = [ const sidebarMain = [
{ label: "空间首页", icon: Home, badge: undefined }, { label: "空间首页", icon: Home, metricKey: undefined },
{ label: "最近项目", icon: ClockIcon, badge: undefined }, { label: "最近项目", icon: ClockIcon, metricKey: "recentProjects" },
{ label: "暂存想法", icon: MessageCircle, badge: 8 }, { label: "暂存想法", icon: MessageCircle, metricKey: "savedIdeas" },
{ label: "草案箱", icon: FileBox, badge: 5 }, { label: "草案箱", icon: FileBox, metricKey: "drafts" },
{ label: "待我确认", icon: CheckCircle2, badge: 12 }, { label: "待我确认", icon: CheckCircle2, metricKey: "pendingConfirmations" },
{ label: "AI 正在推进", icon: Sparkles, badge: 6 }, { label: "AI 正在推进", icon: Sparkles, metricKey: "aiProgress" },
]; ];
const sidebarTools = [ const sidebarTools = [
@ -45,27 +45,36 @@ function ClockIcon(props: SVGProps<SVGSVGElement>) {
); );
} }
export function SpaceSidebar({ activeLabel = "空间首页" }: { activeLabel?: string }) { export function SpaceSidebar({
activeLabel = "空间首页",
metrics,
}: {
activeLabel?: string;
metrics?: Partial<Record<"recentProjects" | "savedIdeas" | "drafts" | "pendingConfirmations" | "aiProgress", number>>;
}) {
return ( return (
<aside className="flex w-[244px] shrink-0 flex-col border-r border-[#e6edf7] bg-[#fbfcff] px-5 py-5"> <aside className="flex w-[244px] shrink-0 flex-col border-r border-[#e6edf7] bg-[#fbfcff] px-5 py-5">
<PMFlowBrandHeader /> <PMFlowBrandHeader />
<nav className="mt-8 space-y-2"> <nav className="mt-8 space-y-2">
{sidebarMain.map((item) => ( {sidebarMain.map((item) => {
<button const badge = item.metricKey ? metrics?.[item.metricKey] : undefined;
key={item.label} return (
type="button" <button
className={cn( key={item.label}
"flex h-11 w-full items-center justify-between rounded-xl px-4 text-sm font-medium transition", type="button"
item.label === activeLabel ? "bg-[#eef4ff] text-[#2463EB]" : "text-slate-600 hover:bg-white hover:text-slate-950" className={cn(
)} "flex h-11 w-full items-center justify-between rounded-xl px-4 text-sm font-medium transition",
> item.label === activeLabel ? "bg-[#eef4ff] text-[#2463EB]" : "text-slate-600 hover:bg-white hover:text-slate-950"
<span className="flex items-center gap-3"> )}
<item.icon className="h-4 w-4" /> >
{item.label} <span className="flex items-center gap-3">
</span> <item.icon className="h-4 w-4" />
{item.badge ? <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{item.badge}</span> : null} {item.label}
</button> </span>
))} {typeof badge === "number" && badge > 0 ? <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{badge}</span> : null}
</button>
);
})}
</nav> </nav>
<div className="my-7 h-px bg-[#e6edf7]" /> <div className="my-7 h-px bg-[#e6edf7]" />
<nav className="space-y-2"> <nav className="space-y-2">

View File

@ -1,124 +1,14 @@
import {
Bell,
ChevronDown,
CircleHelp,
LogOut,
PanelTop,
Repeat2,
Settings,
ShieldCheck,
UserRound,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { AccountSettingsPanel } from "@/features/account-settings/AccountSettingsPanel";
import { import { MobileLogoutSheet } from "@/features/pmflow-os/components/AccountControls";
AccountListItem,
AccountProfileSummary,
MobileLogoutSheet,
} from "@/features/pmflow-os/components/AccountControls";
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
export default function AccountPage() { export default function AccountPage() {
const [logoutSheetOpen, setLogoutSheetOpen] = useState(false); const [logoutSheetOpen, setLogoutSheetOpen] = useState(false);
return ( return (
<> <>
<div className="hidden min-h-full bg-white md:block"> <AccountSettingsPanel onLogout={() => setLogoutSheetOpen(true)} />
<div className="mx-auto max-w-[980px] px-8 py-8">
<div className="mb-7 flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-[-0.04em] text-slate-950">账号与空间</h1>
<p className="mt-2 text-sm text-slate-500">管理当前身份动作。退出登录不会改变项目层级,也不会中断 AI 正在推进的工作。</p>
</div>
<Link to="/space" className="rounded-xl border border-[#dfe7f3] px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-[#2463EB] hover:text-[#2463EB]">
返回个人空间
</Link>
</div>
<div className="grid gap-5 lg:grid-cols-[360px_minmax(0,1fr)]">
<AccountProfileSummary />
<section className="rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#eef4ff] text-[#2463EB]">
<ShieldCheck className="h-5 w-5" />
</span>
<div>
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">退出规则</h2>
<p className="mt-1 text-sm text-slate-500">AI 生成内容保持待确认状态,只有你确认后才会写入项目记忆。</p>
</div>
</div>
<div className="mt-5 grid gap-3 text-sm text-slate-600">
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">AI 推进不会中断,进度会保留在个人空间。</div>
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">草案和待确认事项不会被当作已确认事实。</div>
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">已确认的项目记忆不受退出登录影响。</div>
</div>
</section>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<section className="space-y-3 rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">账号</h2>
<AccountListItem icon={UserRound} title="个人资料" detail="姓名、角色与默认头像" />
<AccountListItem icon={Bell} title="通知设置" detail="待确认、AI 推进和项目动态提醒" />
<AccountListItem icon={PanelTop} title="空间偏好" detail="默认个人空间与页面显示偏好" />
</section>
<section className="space-y-3 rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">支持与账号操作</h2>
<AccountListItem icon={CircleHelp} title="帮助与反馈" detail="查看帮助文档或提交建议" />
<AccountListItem icon={Repeat2} title="切换账号" detail="保留当前项目上下文,切换另一个身份" />
<AccountListItem icon={LogOut} title="退出登录" detail="退出当前身份,保留空间内草案与待确认事项" danger onClick={() => setLogoutSheetOpen(true)} />
</section>
</div>
</div>
</div>
<MobileAccountPage onLogout={() => setLogoutSheetOpen(true)} />
<MobileLogoutSheet open={logoutSheetOpen} onClose={() => setLogoutSheetOpen(false)} /> <MobileLogoutSheet open={logoutSheetOpen} onClose={() => setLogoutSheetOpen(false)} />
</> </>
); );
} }
function MobileAccountPage({ onLogout }: { onLogout: () => void }) {
return (
<div className="min-h-screen bg-white px-4 pb-28 pt-5 text-slate-950 md:hidden">
<div className="flex items-center justify-between">
<PMFlowBrand compact />
<button type="button" className="rounded-2xl border border-[#e6edf7] px-3 py-2 text-sm font-semibold text-slate-700">
个人空间 <ChevronDown className="ml-1 inline h-3.5 w-3.5" />
</button>
</div>
<div className="mt-8">
<AccountProfileSummary />
</div>
<section className="mt-7">
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">账号</h2>
<div className="space-y-3">
<AccountListItem icon={UserRound} title="个人资料" />
<AccountListItem icon={Bell} title="通知设置" />
<AccountListItem icon={PanelTop} title="空间偏好" />
</div>
</section>
<section className="mt-7">
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">支持</h2>
<div className="space-y-3">
<AccountListItem icon={CircleHelp} title="帮助中心" />
<AccountListItem icon={Settings} title="反馈与建议" />
</div>
</section>
<section className="mt-7">
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">账号操作</h2>
<div className="space-y-3">
<AccountListItem icon={Repeat2} title="切换账号" />
<AccountListItem icon={LogOut} title="退出登录" danger onClick={onLogout} />
</div>
</section>
<MobileBottomNav />
</div>
);
}