From 4b2193e828b3883e191976a0e4b254210a28abf4 Mon Sep 17 00:00:00 2001 From: Albert Date: Sun, 26 Apr 2026 10:24:01 +0800 Subject: [PATCH] chore(pmflow): integrate big module lane workbench --- AGENTS.md | 12 +- CLAUDE.md | 16 +- docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md | 47 + docs/architecture/ARCHITECTURE_INVENTORY.md | 2 +- docs/architecture/TASK_BRIEF_TEMPLATE.md | 26 + frontend/src/api/accountSettings.ts | 148 +++ frontend/src/api/space.ts | 391 +++++++ .../account-settings/AccountSettingsPanel.tsx | 768 ++++++++++++++ .../account-settings/accountSettings.test.ts | 63 ++ .../personal-space/PersonalSpacePage.test.tsx | 50 + .../personal-space/PersonalSpacePage.tsx | 959 +++++++++++++----- .../personal-space/components/SpaceChrome.tsx | 55 +- .../features/pmflow-os/pages/AccountPage.tsx | 116 +-- 13 files changed, 2225 insertions(+), 428 deletions(-) create mode 100644 frontend/src/api/accountSettings.ts create mode 100644 frontend/src/api/space.ts create mode 100644 frontend/src/features/account-settings/AccountSettingsPanel.tsx create mode 100644 frontend/src/features/account-settings/accountSettings.test.ts create mode 100644 frontend/src/features/personal-space/PersonalSpacePage.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 1f550a4c..c669508c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,10 +206,14 @@ frontend/ # React 前端 ## 17. Codex 并行开发治理 -- 多个 Codex 线程并行推进时,执行单位必须是 `Packet`,不得以模糊“大模块”并行开工。 -- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / owner_thread / base_ref / read_scope / write_scope / forbidden_paths / shared_files / depends_on / merge_after / verification / docs_to_sync`。 +- 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。 +- 每个 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 必须串行。 - 每轮并行最多一个 `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/`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。 - 一个 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 协议` 为准。 @@ -225,7 +229,11 @@ frontend/ # React 前端 - `backend/app/schemas/*.py` 中的跨域 schema - `backend/alembic/versions/*` - `frontend/src/router/index.tsx` +- `frontend/src/main.tsx` - `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` - `CLAUDE.md` - `docs/architecture/CODE_REFERENCE_INDEX.md` diff --git a/CLAUDE.md b/CLAUDE.md index 94f3f24f..14b32687 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。 - 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。 - 新增前端快回路约定:`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/` 独立分支和独立 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/` 独立分支和独立 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` 为准。 ## 2. 项目定位 @@ -110,12 +110,13 @@ frontend/ - 当前前端重点入口优先看: - `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/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/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 调后端,显式 `?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 真实路径;提供 `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/account-settings/`(`/me` 个人中心与设置;通过 `frontend/src/api/accountSettings.ts` 管理个人资料、默认语言、AI 回答深度、证据展示偏好和通知偏好,移动端使用分组列表与 bottom sheet,不进入 auth core) + - `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=` 读取真实后端 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) - - `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/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` 负责显性展示) @@ -131,8 +132,9 @@ frontend/ - 页面目录化、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/图表页则把重对话框与图表从同步入口拆出。 - `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=`。`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/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=` 调用 `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/personal-space/` 是空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 并生成继续推进、待确认、AI 正在推进、草案箱、最近项目摘要;点击项目/确认/草案只跳回 `/pmflow-os?projectId=` 主流程处理,不在 Space 写 Project Memory 或 execution event。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.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=` 调用 `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/` 是当前唯一登录真源。 ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后) diff --git a/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md b/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md index ff97d78d..312daa6b 100644 --- a/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md +++ b/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md @@ -807,6 +807,53 @@ P0 公共壳冻结已完成,下一批可以进入 `Batch P1` 三模块并行 ## 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 完成内容: diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index 84dd208d..bcb15e29 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -227,7 +227,7 @@ - AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。 - 网页问答显式锚点 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、队列、对象表与详情抽屉。 -- 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. 工具与运行库存 - `backend/scripts/repair_requirement_plan_history.py` diff --git a/docs/architecture/TASK_BRIEF_TEMPLATE.md b/docs/architecture/TASK_BRIEF_TEMPLATE.md index 9794a994..0ae80d28 100644 --- a/docs/architecture/TASK_BRIEF_TEMPLATE.md +++ b/docs/architecture/TASK_BRIEF_TEMPLATE.md @@ -2,11 +2,16 @@ 本模板用于 PMFlow Codex 单线程或并行 Packet 分派。并行任务必须填写 `Read Scope / Write Scope / Forbidden Paths`;没有 `write_scope` 的任务不允许并行执行。 +大模块并行时,`Module Lane` 表示长期产品表面归属,例如 `Shell / Integration`、`Main Flow / PMFlow OS`、`Space`、`Account Settings`。`Packet` 仍是实际执行单位;不能只填写模块名后直接开工。 + ## Meta - Packet ID: `{{PACKET_ID}}` +- Module Lane: `Shell / Integration | Main Flow / PMFlow OS | Space | Account Settings | Other` - Phase: `{{PHASE}}` - Owner: `{{OWNER}}` +- Lane Owner: `{{LANE_OWNER}}` +- Integration Owner: `{{INTEGRATION_OWNER}}` - Generated At: `{{GENERATED_AT}}` - Base Ref: `{{BASE_REF}}` - Head Ref: `{{HEAD_REF}}` @@ -28,6 +33,23 @@ {{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 - Read Scope: @@ -76,6 +98,7 @@ ## Integration Notes - Integration Owner: `{{INTEGRATION_OWNER}}` +- Module Lane Merge Group: `{{MODULE_LANE_MERGE_GROUP}}` - Merge Order: `{{MERGE_ORDER}}` - Expected Commit(s): `{{EXPECTED_COMMITS}}` - Out-of-scope Findings: @@ -86,6 +109,9 @@ - [ ] `base_ref` recorded - [ ] `read_scope` and `write_scope` declared - [ ] `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 - [ ] Code completed - [ ] Contracts synchronized diff --git a/frontend/src/api/accountSettings.ts b/frontend/src/api/accountSettings.ts new file mode 100644 index 00000000..f05735b0 --- /dev/null +++ b/frontend/src/api/accountSettings.ts @@ -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; + +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 = { + 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 | null { + if (!isBrowserStorageAvailable()) return null; + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + 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 | 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 { + const defaults = createDefaultAccountSettings(seed); + return mergeAccountSettings(defaults, readStoredAccountSettings()); +} + +export async function updateAccountSettings(nextSettings: AccountSettings): Promise { + const persisted = { + ...nextSettings, + saveScope: "local" as const, + updatedAt: new Date().toISOString(), + }; + persistAccountSettings(persisted); + return persisted; +} diff --git a/frontend/src/api/space.ts b/frontend/src/api/space.ts new file mode 100644 index 00000000..779c5437 --- /dev/null +++ b/frontend/src/api/space.ts @@ -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 = ["queued", "pushed", "coding", "pr_open", "testing"]; + +function stageLabel(stage?: string | null): string { + const labels: Record = { + idea: "想法", + validation: "验证", + planning: "规划", + development: "开发", + testing: "测试", + release: "发布", + growth: "增长", + }; + return labels[String(stage || "")] || "推进中"; +} + +function statusLabel(status?: string | null): string { + const labels: Record = { + 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; + 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; + 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; + 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; + 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).riskLevel || (value as Record).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 = { + 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( + items: T[] +): T[] { + return [...items].sort((a, b) => dateValue(b) - dateValue(a)); +} + +async function getProjectSnapshot(project: ProjectSummary): Promise { + 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 { + 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(), + }; + }, +}; diff --git a/frontend/src/features/account-settings/AccountSettingsPanel.tsx b/frontend/src/features/account-settings/AccountSettingsPanel.tsx new file mode 100644 index 00000000..1e64ca40 --- /dev/null +++ b/frontend/src/features/account-settings/AccountSettingsPanel.tsx @@ -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["user"]) { + return user?.name || user?.username || "张子墨"; +} + +function getDisplayRole(user: ReturnType["user"]) { + if (user?.role === "admin") return "管理员"; + if (user?.role === "manager") return "项目负责人"; + return user?.role || "产品负责人"; +} + +function getAvatarSeed(user: ReturnType["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(null); + const [activeSheet, setActiveSheet] = useState(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) => { + setDraft((current) => current ? { ...current, profile: { ...current.profile, ...patch } } : current); + }; + const patchAi = (patch: Partial) => { + setDraft((current) => current ? { ...current, aiPreferences: { ...current.aiPreferences, ...patch } } : current); + }; + const patchNotification = (key: AccountNotificationKey, patch: Partial) => { + 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 ; + } + + return ( + <> + + + + ); +} + +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) => void; + onPatchNotification: (key: AccountNotificationKey, patch: Partial) => void; + onPatchProfile: (patch: Partial) => void; + onSave: () => void; +}) { + return ( +
+
+
+
+

我的

+

个人资料与偏好

+

这些设置会影响 PMFlow 的默认语言、AI 回复形态、证据展示和提醒节奏。

+
+
+ + 返回个人空间 + + +
+
+ +
+ + +
+ } title="个人资料" detail="用于 PMFlow 内的称呼、空间标识和通知展示。"> + + + + } title="AI 偏好" detail="控制默认回答深度和证据呈现,不影响运行时模型路由。"> + + + + } title="通知偏好" detail="优先覆盖需要人类拍板、执行回写、发布门禁和失败提醒。"> + + +
+
+
+
+ ); +} + +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) => void; + onPatchNotification: (key: AccountNotificationKey, patch: Partial) => void; + onPatchProfile: (patch: Partial) => void; + onSave: () => void; +}) { + return ( +
+
+ + +
+ +
+ +
+ +
+ +
+ + + } title="个人资料" detail={`${draft.profile.displayName} · ${draft.profile.roleTitle}`} onClick={() => onOpenSheet("profile")} /> + } title="AI 偏好" detail={`${getAnswerDepthTitle(draft.aiPreferences.answerDepth)} · ${getEvidenceTitle(draft.aiPreferences.evidenceDisplay)}`} onClick={() => onOpenSheet("ai")} /> + } title="通知偏好" detail={`${getEnabledNotificationCount(draft)} 类提醒已开启`} onClick={() => onOpenSheet("notifications")} /> + + + + } title="切换账号" detail="保留当前空间上下文" /> + } title="退出登录" detail="草案与待确认事项保留" danger onClick={onLogout} /> + + +
+ +
+ + + + onOpenSheet(null)} + > +
+ +
+
+ + onOpenSheet(null)} + > +
+ +
+
+ + onOpenSheet(null)} + > +
+ +
+
+
+ ); +} + +function ProfileSummary({ avatarSrc, draft, mobile = false }: { avatarSrc: string; draft: AccountSettings; mobile?: boolean }) { + return ( +
+
+ {`${draft.profile.displayName}头像`} +
+
{draft.profile.displayName}
+
{draft.profile.roleTitle} · 个人空间
+
+ + {draft.profile.defaultLanguage === "zh-CN" ? "默认中文" : "Default English"} +
+
+
+
上次保存:{formatUpdatedAt(draft.updatedAt)}
+
+ ); +} + +function SettingsSection({ icon, title, detail, children }: { icon: ReactNode; title: string; detail: string; children: ReactNode }) { + return ( +
+
+ {icon} +
+

{title}

+

{detail}

+
+
+ {children} +
+ ); +} + +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 ( +
+
+ + + +
+
{status.title}
+
{status.detail}
+
+
+
+ ); +} + +function ProfileFields({ + profile, + mobile = false, + onPatchProfile, +}: { + profile: AccountProfileSettings; + mobile?: boolean; + onPatchProfile: (patch: Partial) => void; +}) { + return ( +
+ + onPatchProfile({ displayName: event.target.value })} + className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30" + /> + + + onPatchProfile({ roleTitle: event.target.value })} + className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30" + /> + + +
+ + onPatchProfile({ email: event.target.value })} + className="h-11 rounded-lg border-[#dfe7f3] pl-9 focus-visible:ring-[#2463EB]/30" + placeholder="用于通知摘要" + /> +
+
+ + onPatchProfile({ defaultLanguage: value })} + /> + +
+ ); +} + +function AiPreferenceFields({ + aiPreferences, + mobile = false, + onPatchAi, +}: { + aiPreferences: AccountAiPreferenceSettings; + mobile?: boolean; + onPatchAi: (patch: Partial) => void; +}) { + return ( +
+ + onPatchAi({ answerDepth: value })} + /> + + + onPatchAi({ evidenceDisplay: value })} + /> + +
+ ); +} + +function NotificationFields({ + notifications, + mobile = false, + onPatchNotification, +}: { + notifications: AccountSettings["notifications"]; + mobile?: boolean; + onPatchNotification: (key: AccountNotificationKey, patch: Partial) => void; +}) { + return ( +
+ {accountNotificationKeys.map((key) => { + const item = notifications[key]; + const label = accountNotificationLabels[key]; + return ( +
+
+
+
{label.title}
+
{label.detail}
+
+ onPatchNotification(key, { enabled })} + className="mt-1 data-[state=checked]:bg-[#2463EB]" + /> +
+
+ onPatchNotification(key, { desktop })} + /> + onPatchNotification(key, { emailDigest })} + /> +
+
+ ); + })} +
+ ); +} + +function ChannelToggle({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function SegmentedOptions({ + options, + value, + onChange, +}: { + options: Array<{ value: T; title: string; detail: string }>; + value: T; + onChange: (value: T) => void; +}) { + return ( +
+ {options.map((option) => { + const selected = value === option.value; + return ( + + ); + })} +
+ ); +} + +function LabeledControl({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function ActionButton({ + icon, + label, + detail, + danger, + onClick, +}: { + icon: ReactNode; + label: string; + detail: string; + danger?: boolean; + onClick?: () => void; +}) { + return ( + + ); +} + +function MobileGroup({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function MobileSettingRow({ + icon, + title, + detail, + danger, + onClick, +}: { + icon: ReactNode; + title: string; + detail?: string; + danger?: boolean; + onClick?: () => void; +}) { + return ( + + ); +} + +function AccountSettingsSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +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 || "内联"; +} diff --git a/frontend/src/features/account-settings/accountSettings.test.ts b/frontend/src/features/account-settings/accountSettings.test.ts new file mode 100644 index 00000000..04b86e68 --- /dev/null +++ b/frontend/src/features/account-settings/accountSettings.test.ts @@ -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"); + }); +}); diff --git a/frontend/src/features/personal-space/PersonalSpacePage.test.tsx b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx new file mode 100644 index 00000000..63955866 --- /dev/null +++ b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx @@ -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: () =>