feat(pmflow-os): respect release notification preferences

This commit is contained in:
Albert 2026-04-26 13:24:43 +08:00
parent 875b9292e9
commit 32af4226f2
5 changed files with 208 additions and 12 deletions

View File

@ -807,6 +807,35 @@ P0 公共壳冻结已完成,下一批可以进入 `Batch P1` 三模块并行
## 8. 进度台账 ## 8. 进度台账
### 2026-04-26 / release-growth-p2-notification-preference-consume / implemented
完成内容:
- `ReleaseGrowthPage` 真实模式读取 Account Settings 通知偏好,通过 `shouldShowAccountNotification(settings, "releaseGate")` 控制发布门禁强调提醒,通过 `shouldShowAccountNotification(settings, "failureAlert")` 控制失败强调提醒。
- `releaseGate` 偏好关闭时,页面不展示“发布门禁提醒”强调卡,也降低缺证据 / 阻塞门禁视觉强调;底层 `releaseGate` 状态、执行证据、阻塞原因、下一步动作和发布验证记录仍完整展示。
- `failureAlert` 偏好关闭时,失败 evidence 与阻塞原因仍可见,但不展示额外醒目的“失败提醒”卡,也不把阻塞原因渲染成额外告警态。
- 本阶段只消费本地页面内提醒偏好,不接邮件、桌面推送或通知中心;不修改 Account Settings 合同、PMFlow OS store、后端发布验证 API、router、Space 或 Account 页面。
- `store.api.test.tsx` 补充 ReleaseGrowth 偏好开启 / 关闭两条路径,覆盖提醒展示、静默展示和 release evidence 仍可见。
涉及文件:
- `frontend/src/features/pmflow-os/pages/ReleaseGrowthPage.tsx`
- `frontend/src/features/pmflow-os/store.api.test.tsx`
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
- `docs/architecture/CODE_REFERENCE_INDEX.md`
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
验证:
- `cd frontend && npm run test -- src/features/pmflow-os` 通过:`2 passed / 6 passed`。
- `cd frontend && npm run typecheck` 未通过:当前前端树缺失整组共享 `src/lib/*` 模块(例如 `@/lib/api`、`@/lib/utils`、`@/lib/datetime`、`@/lib/display`、`@/lib/rbac`),影响全仓大量既有文件和 forbidden `router/index.tsx`;本 Packet 未越界修复。
- `cd frontend && npm run build` 同样停在上述共享 `src/lib/*` 缺失的 TypeScript 解析错误,未进入 Vite build。
未完成或风险:
- 本阶段不隐藏业务真相,只控制提醒表达强度;发布证据、审计状态和 validation run 仍以 PMFlow OS 真实读侧为准。
- 全量前端 typecheck/build 需由 Shell / Integration owner 或共享前端基础模块 owner 先恢复 `frontend/src/lib/*` 后再重新验证。
### 2026-04-26 / release-validation-p1-real-run-records / implemented ### 2026-04-26 / release-validation-p1-real-run-records / implemented
完成内容: 完成内容:

View File

@ -198,7 +198,7 @@
- `frontend/src/features/project-launch/types.ts`、`mockData.ts`、`useProjectLaunchState.ts` 是新项目启动台状态层;默认真实模式通过 `frontend/src/api/pmflowLaunch.ts` 生成草案并确认创建真实项目,API 失败进入明确失败态,不静默 fallback 成 mock 成功;`mockData.ts` 仅在显式 `?demo=1` 或 `?dataSource=demo` 下作为演示状态机使用。`components/LaunchShell.tsx` 负责桌面/移动分流,桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,`LaunchCanvases.tsx` 和 `MobileLaunchLayout.tsx` 承接高保真图中的意图输入、AI 工作、人类确认、草案修改、生成失败、创建成功过渡与移动端 bottom sheet 交互。 - `frontend/src/features/project-launch/types.ts`、`mockData.ts`、`useProjectLaunchState.ts` 是新项目启动台状态层;默认真实模式通过 `frontend/src/api/pmflowLaunch.ts` 生成草案并确认创建真实项目,API 失败进入明确失败态,不静默 fallback 成 mock 成功;`mockData.ts` 仅在显式 `?demo=1` 或 `?dataSource=demo` 下作为演示状态机使用。`components/LaunchShell.tsx` 负责桌面/移动分流,桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,`LaunchCanvases.tsx` 和 `MobileLaunchLayout.tsx` 承接高保真图中的意图输入、AI 工作、人类确认、草案修改、生成失败、创建成功过渡与移动端 bottom sheet 交互。
- `frontend/src/features/account-settings/AccountSettingsPanel.tsx` 是 `/me` 设置页实现:桌面端为个人资料摘要 + 分区设置面板,移动端为分组列表 + `MobileBottomSheet` 编辑;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,并在通知偏好区展示页面内提醒消费说明。`frontend/src/api/accountSettings.ts` 是前端合同层;有 token 时优先调用 `backend/app/api/account_settings_api/`,失败时回落浏览器本机暂存;P2 新增 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,将四类通知偏好映射为本地 UI 的待确认、Execution Bridge、Release/Growth 和失败提醒显示/静默状态,不代表邮件、桌面推送或系统通知中心已接入。`backend/app/schemas/account_settings.py` 定义账号偏好合同,`backend/app/services/account_settings_service.py` 当前复用既有 `integrations` 表按 `tenant_id + user_id` 保存 `account_settings_preferences` 配置包,`backend/app/api/router.py` 已挂载该 API;未改用户表、OAuth/JWT/auth core 或 PMFlow OS truth store。 - `frontend/src/features/account-settings/AccountSettingsPanel.tsx` 是 `/me` 设置页实现:桌面端为个人资料摘要 + 分区设置面板,移动端为分组列表 + `MobileBottomSheet` 编辑;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,并在通知偏好区展示页面内提醒消费说明。`frontend/src/api/accountSettings.ts` 是前端合同层;有 token 时优先调用 `backend/app/api/account_settings_api/`,失败时回落浏览器本机暂存;P2 新增 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,将四类通知偏好映射为本地 UI 的待确认、Execution Bridge、Release/Growth 和失败提醒显示/静默状态,不代表邮件、桌面推送或系统通知中心已接入。`backend/app/schemas/account_settings.py` 定义账号偏好合同,`backend/app/services/account_settings_service.py` 当前复用既有 `integrations` 表按 `tenant_id + user_id` 保存 `account_settings_preferences` 配置包,`backend/app/api/router.py` 已挂载该 API;未改用户表、OAuth/JWT/auth core 或 PMFlow OS truth store。
- `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=<id>` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、Execution Handoff create/consume、execution progress writeback 与 release validation run create 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功。 - `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=<id>` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、Execution Handoff create/consume、execution progress writeback 与 release validation run create 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功。
- `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate` 与 `latestValidationRun`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作和发布验证记录创建动作,不执行发布命令、不展示 mock 发布结果;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照;当前仍不调用真实 AI 提取,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 - `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate` 与 `latestValidationRun`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作和发布验证记录创建动作,不执行发布命令、不展示 mock 发布结果;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态或 validation run;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照;当前仍不调用真实 AI 提取,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。
- PMFlow OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Change Review / commit 服务位于 `backend/app/services/pmflow_change_review_service.py`,Pydantic 合同位于 `backend/app/schemas/pmflow_os.py`;当前聚合 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,`Thread / Message` 来自 `pmflow_threads / pmflow_thread_messages`,`ChangeSet / ChangeItem / Approval / EvidenceLink` 来自 `backend/app/models/pmflow_change_review.py` 对应真实表。C1 已实现 message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend/frontend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit` 与真实模式前端接入,默认 atomic,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,校验 task/repo project 边界,生成/复用 `PromptPackage(package_kind='execution_handoff')` 并写 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,校验 execution handoff package 后更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并写 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 在 `ExecutionSummaryProjection.releaseGate` 中从 `ProgressEvent` 推导发布门禁状态,失败事件 fail-closed 阻塞发布,无证据返回 empty/warning;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,复用现有 `ReleaseValidationRun` 模型固化当前 `releaseGate` 快照、人工决策、证据、阻塞原因和下一步动作,并在 `releaseGate.latestValidationRun` 返回最新记录;这些步骤都不直接执行本机命令或真实发布命令;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。 - PMFlow OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Change Review / commit 服务位于 `backend/app/services/pmflow_change_review_service.py`,Pydantic 合同位于 `backend/app/schemas/pmflow_os.py`;当前聚合 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,`Thread / Message` 来自 `pmflow_threads / pmflow_thread_messages`,`ChangeSet / ChangeItem / Approval / EvidenceLink` 来自 `backend/app/models/pmflow_change_review.py` 对应真实表。C1 已实现 message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend/frontend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit` 与真实模式前端接入,默认 atomic,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,校验 task/repo project 边界,生成/复用 `PromptPackage(package_kind='execution_handoff')` 并写 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,校验 execution handoff package 后更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并写 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 在 `ExecutionSummaryProjection.releaseGate` 中从 `ProgressEvent` 推导发布门禁状态,失败事件 fail-closed 阻塞发布,无证据返回 empty/warning;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,复用现有 `ReleaseValidationRun` 模型固化当前 `releaseGate` 快照、人工决策、证据、阻塞原因和下一步动作,并在 `releaseGate.latestValidationRun` 返回最新记录;这些步骤都不直接执行本机命令或真实发布命令;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。
- PMFlow Launch 后端 Phase B 创建链位于 `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`;`POST /pmflow-launch/drafts` 只写 `pmflow_launch_drafts`,`POST /pmflow-launch/drafts/{draft_id}/confirm` 在同一事务内写 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`,并把 `launch_draft_id / thread_id / thread_message_id` 写入正式对象 metadata。 - PMFlow Launch 后端 Phase B 创建链位于 `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`;`POST /pmflow-launch/drafts` 只写 `pmflow_launch_drafts`,`POST /pmflow-launch/drafts/{draft_id}/confirm` 在同一事务内写 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`,并把 `launch_draft_id / thread_id / thread_message_id` 写入正式对象 metadata。
- `frontend/src/features/pmflow-os/components/ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx` 是高保真原型新增的行为组件层:分别承载变更 diff 审查块、工作台对象卡组、执行桥上下文/日志卡片、全局上下文条与 AI 副驾、账号菜单/退出登录确认交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组提示、提醒、确认与移动端 bottom sheet 基础组件,避免再把这些业务片段内联塞回 `pages/`。 - `frontend/src/features/pmflow-os/components/ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx` 是高保真原型新增的行为组件层:分别承载变更 diff 审查块、工作台对象卡组、执行桥上下文/日志卡片、全局上下文条与 AI 副驾、账号菜单/退出登录确认交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组提示、提醒、确认与移动端 bottom sheet 基础组件,避免再把这些业务片段内联塞回 `pages/`。

View File

@ -106,9 +106,9 @@
- PMFlow OS 前端 MVP 产品域:参考 `frontend/src/features/pmflow-os/` - PMFlow OS 前端 MVP 产品域:参考 `frontend/src/features/pmflow-os/`
- 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;本地预览路由入口为 `/space`,覆盖继续推进、待我确认、发布关注、执行中 / AI 正在推进、暂存想法、草案箱与最近项目,点击项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`,发布关注项进入 `/release-growth?projectId=<id>`;Space 只读聚合项目摘要、PMFlow OS `truth-state / execution-summary.releaseGate`,不直接写 Project Memory、ChangeSet 状态或 execution event;暂存想法后端只读聚合尚未接入时必须显示 empty/warning,不得用 mock 数据冒充真实内容;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该组件 - 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;本地预览路由入口为 `/space`,覆盖继续推进、待我确认、发布关注、执行中 / AI 正在推进、暂存想法、草案箱与最近项目,点击项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`,发布关注项进入 `/release-growth?projectId=<id>`;Space 只读聚合项目摘要、PMFlow OS `truth-state / execution-summary.releaseGate`,不直接写 Project Memory、ChangeSet 状态或 execution event;暂存想法后端只读聚合尚未接入时必须显示 empty/warning,不得用 mock 数据冒充真实内容;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该组件
- 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 draft/confirm API,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用 `mockData.ts` 本地演示状态机;本地预览路由入口为 `/project-launch` - 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 draft/confirm API,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用 `mockData.ts` 本地演示状态机;本地预览路由入口为 `/project-launch`
- Account Settings / `/me` 前端产品域与 P1/P2 偏好合同:参考 `frontend/src/features/account-settings/AccountSettingsPanel.tsx`、`frontend/src/features/pmflow-os/pages/AccountPage.tsx`、`frontend/src/api/accountSettings.ts`、`backend/app/api/account_settings_api/`、`backend/app/schemas/account_settings.py` 与 `backend/app/services/account_settings_service.py`;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,移动端使用分组列表与 bottom sheet,账户退出仍复用 `AccountControls.tsx`,不改 OAuth/JWT/auth core;P1 后端合同提供 `GET /account-settings/me` 与 `PUT /account-settings/me`,已由 `backend/app/api/router.py` 挂入核心路由组,按当前 `tenant_id + user_id` 保存账号偏好,前端有 token 时优先账号同步,后端不可用时明确回落 `仅本机暂存 / 保存失败`;P2 在 `frontend/src/api/accountSettings.ts` 提供 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,把通知偏好映射为页面内提醒消费模型,只控制本地 UI 的待确认、执行回写、发布门禁和失败提醒展示,不代表真实邮件/桌面推送已接入 - Account Settings / `/me` 前端产品域与 P1/P2 偏好合同:参考 `frontend/src/features/account-settings/AccountSettingsPanel.tsx`、`frontend/src/features/pmflow-os/pages/AccountPage.tsx`、`frontend/src/api/accountSettings.ts`、`backend/app/api/account_settings_api/`、`backend/app/schemas/account_settings.py` 与 `backend/app/services/account_settings_service.py`;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,移动端使用分组列表与 bottom sheet,账户退出仍复用 `AccountControls.tsx`,不改 OAuth/JWT/auth core;P1 后端合同提供 `GET /account-settings/me` 与 `PUT /account-settings/me`,已由 `backend/app/api/router.py` 挂入核心路由组,按当前 `tenant_id + user_id` 保存账号偏好,前端有 token 时优先账号同步,后端不可用时明确回落 `仅本机暂存 / 保存失败`;P2 在 `frontend/src/api/accountSettings.ts` 提供 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,把通知偏好映射为页面内提醒消费模型,只控制本地 UI 的待确认、执行回写、发布门禁和失败提醒展示,不代表真实邮件/桌面推送已接入;ReleaseGrowthPage 当前消费 `releaseGate` 与 `failureAlert` 两个偏好,只静默强调型提醒,不隐藏 release evidence、阻塞原因、审计状态或 validation run
- PMFlow OS 共享 truth store 与主链状态动作:参考 `frontend/src/features/pmflow-os/store.tsx`;当前支持 `?projectId=<id>` 走 React Query 读取真实 `truth-state` 与 `execution-summary`,真实模式下 thread message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、Execution Handoff create/consume、execution progress writeback、release validation run create 均调后端并 refetch,不本地 promote 入 Project Memory、不伪造 execution event 或发布验证记录;`?demo=1` 才启用本地 mock 演示状态机和本地动作;项目内 AI 结构化闭环状态机为 `command_idle / ai_extracting / extraction_ready / review_open / writeback_success` - PMFlow OS 共享 truth store 与主链状态动作:参考 `frontend/src/features/pmflow-os/store.tsx`;当前支持 `?projectId=<id>` 走 React Query 读取真实 `truth-state` 与 `execution-summary`,真实模式下 thread message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、Execution Handoff create/consume、execution progress writeback、release validation run create 均调后端并 refetch,不本地 promote 入 Project Memory、不伪造 execution event 或发布验证记录;`?demo=1` 才启用本地 mock 演示状态机和本地动作;项目内 AI 结构化闭环状态机为 `command_idle / ai_extracting / extraction_ready / review_open / writeback_success`
- PMFlow OS 产品页入口:参考 `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx`、`StructuredExtractionPage.tsx`、`ChangeReviewPage.tsx`、`MemoryWritebackResultPage.tsx`、`WorkGraphPage.tsx`、`ExecutionBridgePage.tsx`、`ReleaseGrowthPage.tsx`、`TeamLayerPage.tsx`、`ProjectMemoryPage.tsx`、`AccountPage.tsx`;`ReleaseGrowthPage.tsx` 真实模式只展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作和 `latestValidationRun`,缺证据时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run,但仍不执行部署命令;静态高保真发布/增长数据仅保留在显式 demo 模式 - PMFlow OS 产品页入口:参考 `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx`、`StructuredExtractionPage.tsx`、`ChangeReviewPage.tsx`、`MemoryWritebackResultPage.tsx`、`WorkGraphPage.tsx`、`ExecutionBridgePage.tsx`、`ReleaseGrowthPage.tsx`、`TeamLayerPage.tsx`、`ProjectMemoryPage.tsx`、`AccountPage.tsx`;`ReleaseGrowthPage.tsx` 真实模式只展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作和 `latestValidationRun`,缺证据时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run,但仍不执行部署命令;同时消费 Account notification preference helper,`releaseGate` 关闭时静默发布门禁强调提醒,`failureAlert` 关闭时静默失败强调提醒,所有真实证据和审计状态仍可见;静态高保真发布/增长数据仅保留在显式 demo 模式
- PMFlow OS 行为组件:参考 `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx`、`ProjectStageRail.tsx`、`ThreadComposer.tsx`、`ExtractionRail.tsx`、`ChangeGroupPanel.tsx`、`ObjectGraphSection.tsx`、`ContextPacketPanel.tsx`、`ExecutionTimeline.tsx`、`ReleaseGatePanel.tsx`、`TeamFeedPanel.tsx`、`ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx`、`MobileBottomNav.tsx`;其中 `ProjectStructuredLoop.tsx` 承接 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel`,`PMFlowOsFrame.tsx` 承接项目内面包屑和项目切换器,`AccountControls.tsx` 承接桌面头像菜单、退出登录确认与移动端 bottom sheet,`FeedbackPrimitives.tsx` 承接 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet`,`MobileBottomNav.tsx` 承接正式应用壳移动端固定底部导航 - PMFlow OS 行为组件:参考 `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx`、`ProjectStageRail.tsx`、`ThreadComposer.tsx`、`ExtractionRail.tsx`、`ChangeGroupPanel.tsx`、`ObjectGraphSection.tsx`、`ContextPacketPanel.tsx`、`ExecutionTimeline.tsx`、`ReleaseGatePanel.tsx`、`TeamFeedPanel.tsx`、`ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx`、`MobileBottomNav.tsx`;其中 `ProjectStructuredLoop.tsx` 承接 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel`,`PMFlowOsFrame.tsx` 承接项目内面包屑和项目切换器,`AccountControls.tsx` 承接桌面头像菜单、退出登录确认与移动端 bottom sheet,`FeedbackPrimitives.tsx` 承接 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet`,`MobileBottomNav.tsx` 承接正式应用壳移动端固定底部导航
- PMFlow OS 原型状态回归测试:参考 `frontend/src/features/pmflow-os/store.test.ts`;真实 API 动作回归参考 `frontend/src/features/pmflow-os/store.api.test.tsx` - PMFlow OS 原型状态回归测试:参考 `frontend/src/features/pmflow-os/store.test.ts`;真实 API 动作回归参考 `frontend/src/features/pmflow-os/store.api.test.tsx`
- Project Launch 真实/演示分支回归测试:参考 `frontend/src/features/project-launch/useProjectLaunchState.test.ts` - Project Launch 真实/演示分支回归测试:参考 `frontend/src/features/project-launch/useProjectLaunchState.test.ts`

View File

@ -18,18 +18,41 @@ import {
TestTube2, TestTube2,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { getAccountSettings, shouldShowAccountNotification } from "@/api/accountSettings";
import type { import type {
PMFlowReleaseGateEvidence, PMFlowReleaseGateEvidence,
PMFlowReleaseValidationDecision, PMFlowReleaseValidationDecision,
PMFlowReleaseValidationRun, PMFlowReleaseValidationRun,
} from "@/api/pmflowOs"; } from "@/api/pmflowOs";
import { usePMFlowOsStore } from "@/features/pmflow-os/store"; import { usePMFlowOsStore } from "@/features/pmflow-os/store";
import { cn } from "@/lib/utils";
type StatusTone = "success" | "warning" | "danger" | "neutral" | "info"; type StatusTone = "success" | "warning" | "danger" | "neutral" | "info";
function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
function Button({
variant,
className,
type = "button",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "outline" }) {
return (
<button
type={type}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
variant === "outline" ? "border bg-white hover:bg-slate-50" : "",
className
)}
{...props}
/>
);
}
const gateItems = [ const gateItems = [
{ label: "功能完整性", status: "通过", detail: "核心流程与功能验证完成", tone: "success" as const }, { label: "功能完整性", status: "通过", detail: "核心流程与功能验证完成", tone: "success" as const },
{ label: "性能基线", status: "通过", detail: "P95 响应时间 < 800ms", tone: "success" as const }, { label: "性能基线", status: "通过", detail: "P95 响应时间 < 800ms", tone: "success" as const },
@ -370,6 +393,15 @@ function evidenceIcon(type: string) {
return CheckCircle2; return CheckCircle2;
} }
function hasFailureSignal(gate?: { status?: string; evidence?: PMFlowReleaseGateEvidence[] }) {
if (!gate) return false;
if (gate.status === "blocked") return true;
return (gate.evidence || []).some((item) => {
const type = String(item.type || "").toLowerCase();
return type.includes("fail") || type.includes("block");
});
}
function EvidenceRow({ item }: { item: PMFlowReleaseGateEvidence }) { function EvidenceRow({ item }: { item: PMFlowReleaseGateEvidence }) {
const Icon = evidenceIcon(item.type); const Icon = evidenceIcon(item.type);
return ( return (
@ -410,9 +442,19 @@ function RealReleaseGrowthPage({ store }: { store: PMFlowOsStoreSnapshot }) {
} = store; } = store;
const gate = executionSummary?.releaseGate; const gate = executionSummary?.releaseGate;
const latestValidationRun = gate?.latestValidationRun; const latestValidationRun = gate?.latestValidationRun;
const accountSettingsQuery = useQuery({
queryKey: ["account-settings", "release-growth-notifications"],
queryFn: () => getAccountSettings(),
staleTime: 60_000,
});
const isLoading = dataSourceStatus === "loading" || executionSummaryStatus === "loading"; const isLoading = dataSourceStatus === "loading" || executionSummaryStatus === "loading";
const warnings = [...projectionWarnings, ...(executionSummary?.warnings || [])]; const warnings = [...projectionWarnings, ...(executionSummary?.warnings || [])];
const emptyMode = dataSourceMode === "empty" || gate?.status === "empty" || !gate; const emptyMode = dataSourceMode === "empty" || gate?.status === "empty" || !gate;
const releaseGateReminderEnabled = shouldShowAccountNotification(accountSettingsQuery.data, "releaseGate");
const failureAlertReminderEnabled = shouldShowAccountNotification(accountSettingsQuery.data, "failureAlert");
const failureSignal = hasFailureSignal(gate);
const showReleaseGateReminder = releaseGateReminderEnabled && !emptyMode;
const showFailureAlertReminder = failureAlertReminderEnabled && failureSignal;
const canCreateValidationRun = Boolean(!emptyMode && gate?.evidence?.length); const canCreateValidationRun = Boolean(!emptyMode && gate?.evidence?.length);
const handleCreateValidationRun = (decision: PMFlowReleaseValidationDecision) => { const handleCreateValidationRun = (decision: PMFlowReleaseValidationDecision) => {
const decisionLabel = decision === "enter_release_candidate" ? "进入发布候选" : "保持阻塞"; const decisionLabel = decision === "enter_release_candidate" ? "进入发布候选" : "保持阻塞";
@ -450,9 +492,39 @@ function RealReleaseGrowthPage({ store }: { store: PMFlowOsStoreSnapshot }) {
</Card> </Card>
) : null} ) : null}
{showReleaseGateReminder ? (
<Card className="border-blue-100 bg-blue-50 p-4">
<div className="flex gap-3 text-sm text-blue-700">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-semibold">发布门禁提醒</div>
<div className="mt-1 leading-6">{gate?.summary || "发布门禁状态已更新,底层证据仍以 execution-summary 为准。"}</div>
</div>
</div>
</Card>
) : null}
{showFailureAlertReminder ? (
<Card className="border-rose-100 bg-rose-50 p-4">
<div className="flex gap-3 text-sm text-rose-700">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-semibold">失败提醒</div>
<div className="mt-1 leading-6">检测到失败或阻塞 evidence,发布验证保持 fail-closed;证据与审计状态仍保留在下方。</div>
</div>
</div>
</Card>
) : null}
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<main className="space-y-5"> <main className="space-y-5">
<Card className={cn("p-5", gate?.status === "blocked" && "border-rose-200 bg-rose-50", gate?.status === "ready" && "border-emerald-200 bg-emerald-50")}> <Card
className={cn(
"p-5",
gate?.status === "blocked" && showFailureAlertReminder && "border-rose-200 bg-rose-50",
gate?.status === "ready" && showReleaseGateReminder && "border-emerald-200 bg-emerald-50"
)}
>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
<StatusChip label={gateLabel(gate?.status)} tone={gateTone(gate?.status)} /> <StatusChip label={gateLabel(gate?.status)} tone={gateTone(gate?.status)} />
@ -471,9 +543,9 @@ function RealReleaseGrowthPage({ store }: { store: PMFlowOsStoreSnapshot }) {
</Card> </Card>
{emptyMode ? ( {emptyMode ? (
<Card className="border-orange-100 bg-orange-50 p-5"> <Card className={cn("p-5", releaseGateReminderEnabled ? "border-orange-100 bg-orange-50" : "border-[#e5edf8] bg-white")}>
<div className="flex gap-3"> <div className="flex gap-3">
<ShieldAlert className="mt-1 h-5 w-5 shrink-0 text-orange-500" /> <ShieldAlert className={cn("mt-1 h-5 w-5 shrink-0", releaseGateReminderEnabled ? "text-orange-500" : "text-slate-400")} />
<div> <div>
<h2 className="text-lg font-semibold text-slate-950">没有可用于发布判断的执行证据</h2> <h2 className="text-lg font-semibold text-slate-950">没有可用于发布判断的执行证据</h2>
<p className="mt-2 text-sm leading-6 text-slate-600"> <p className="mt-2 text-sm leading-6 text-slate-600">
@ -516,7 +588,15 @@ function RealReleaseGrowthPage({ store }: { store: PMFlowOsStoreSnapshot }) {
<SectionTitle title="阻塞原因" icon={AlertTriangle} /> <SectionTitle title="阻塞原因" icon={AlertTriangle} />
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{(gate?.blockingReasons || []).map((item) => ( {(gate?.blockingReasons || []).map((item) => (
<div key={item} className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-sm leading-6 text-rose-700"> <div
key={item}
className={cn(
"rounded-lg border px-3 py-2 text-sm leading-6",
showFailureAlertReminder
? "border-rose-100 bg-rose-50 text-rose-700"
: "border-[#e6edf7] bg-[#fbfdff] text-slate-600"
)}
>
{item} {item}
</div> </div>
))} ))}

View File

@ -1,8 +1,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAccountSettings } from "@/api/accountSettings";
import { import {
acceptPMFlowChangeItem, acceptPMFlowChangeItem,
commitPMFlowChangeSet, commitPMFlowChangeSet,
@ -15,12 +16,25 @@ import {
editPMFlowChangeItem, editPMFlowChangeItem,
getPMFlowExecutionSummary, getPMFlowExecutionSummary,
getPMFlowTruthState, getPMFlowTruthState,
type PMFlowExecutionSummary,
type PMFlowReleaseGate, type PMFlowReleaseGate,
} from "@/api/pmflowOs"; } from "@/api/pmflowOs";
import { createInitialPMFlowState } from "@/features/pmflow-os/mockData"; import { createInitialPMFlowState } from "@/features/pmflow-os/mockData";
import { PMFlowOsProvider, usePMFlowOsStore } from "@/features/pmflow-os/store"; import { PMFlowOsProvider, usePMFlowOsStore } from "@/features/pmflow-os/store";
import type { ProjectTruthState } from "@/features/pmflow-os/types"; import type { ProjectTruthState } from "@/features/pmflow-os/types";
vi.mock("@/lib/utils", () => ({
cn: (...inputs: Array<string | false | null | undefined>) => inputs.filter(Boolean).join(" "),
}));
vi.mock("@/api/accountSettings", () => ({
getAccountSettings: vi.fn(),
shouldShowAccountNotification: vi.fn(
(settings: { notifications?: Record<string, { enabled?: boolean }> } | null | undefined, key: string) =>
settings?.notifications?.[key]?.enabled ?? true
),
}));
vi.mock("@/api/pmflowOs", () => ({ vi.mock("@/api/pmflowOs", () => ({
getPMFlowTruthState: vi.fn(), getPMFlowTruthState: vi.fn(),
getPMFlowExecutionSummary: vi.fn(), getPMFlowExecutionSummary: vi.fn(),
@ -39,6 +53,7 @@ vi.mock("@/api/pmflowOs", () => ({
const getTruthStateMock = vi.mocked(getPMFlowTruthState); const getTruthStateMock = vi.mocked(getPMFlowTruthState);
const getExecutionSummaryMock = vi.mocked(getPMFlowExecutionSummary); const getExecutionSummaryMock = vi.mocked(getPMFlowExecutionSummary);
const getAccountSettingsMock = vi.mocked(getAccountSettings);
const createThreadMessageMock = vi.mocked(createPMFlowThreadMessage); const createThreadMessageMock = vi.mocked(createPMFlowThreadMessage);
const createExecutionHandoffMock = vi.mocked(createPMFlowExecutionHandoff); const createExecutionHandoffMock = vi.mocked(createPMFlowExecutionHandoff);
const consumeExecutionHandoffMock = vi.mocked(consumePMFlowExecutionHandoff); const consumeExecutionHandoffMock = vi.mocked(consumePMFlowExecutionHandoff);
@ -105,12 +120,56 @@ function releaseGate(status: "ready" | "blocked" | "needs_confirmation" | "empty
{ key: "test", label: "测试证据", status: status === "blocked" ? "failed" : "passed", detail: "真实测试回写" }, { key: "test", label: "测试证据", status: status === "blocked" ? "failed" : "passed", detail: "真实测试回写" },
], ],
evidence: [ evidence: [
{ id: "703", type: "test_passed", summary: "真实测试回写", prNumber: 88 }, {
id: "703",
type: status === "blocked" ? "test_failed" : "test_passed",
summary: status === "blocked" ? "测试失败" : "真实测试回写",
prNumber: 88,
},
], ],
updatedAt: "2026-04-26T00:00:00", updatedAt: "2026-04-26T00:00:00",
}; };
} }
function accountSettingsPreference({ releaseGate = true, failureAlert = true }: { releaseGate?: boolean; failureAlert?: boolean } = {}) {
return {
profile: { displayName: "Owner", roleTitle: "产品负责人", email: "", 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: releaseGate, desktop: false, emailDigest: false },
failureAlert: { enabled: failureAlert, desktop: false, emailDigest: false },
},
saveScope: "local",
updatedAt: "2026-04-26T00:00:00",
} as const;
}
function executionSummaryWithGate(gate: PMFlowReleaseGate): PMFlowExecutionSummary {
const executionStatus: PMFlowExecutionSummary["execution"]["status"] = gate.status === "blocked" ? "failed" : "testing";
return {
projectId: "123",
execution: {
taskId: "task-99",
status: executionStatus,
prompt: "真实 prompt package",
commitLog: ["真实进度事件"],
prStatus: "PR #88 · linked",
testSummary: gate.status === "blocked" ? "测试失败" : "真实测试回写",
writebackStatus: gate.status === "blocked" ? "failed" : "testing",
},
releaseGate: gate,
devSync: { active_task_count: 1 },
promptPackage: null,
repoBindings: [],
activeDevTasks: [],
recentProgressEvents: [],
recentDeliveries: [],
warnings: [],
};
}
function wrapper() { function wrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
@ -126,6 +185,7 @@ describe("PMFlowOsProvider real change review actions", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.history.pushState({}, "", "/pmflow-os?projectId=123"); window.history.pushState({}, "", "/pmflow-os?projectId=123");
getAccountSettingsMock.mockResolvedValue(accountSettingsPreference());
getTruthStateMock.mockResolvedValue(realState()); getTruthStateMock.mockResolvedValue(realState());
getExecutionSummaryMock.mockResolvedValue({ getExecutionSummaryMock.mockResolvedValue({
projectId: "123", projectId: "123",
@ -392,6 +452,7 @@ describe("PMFlowOsProvider real change review actions", () => {
}); });
afterEach(() => { afterEach(() => {
cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -492,6 +553,32 @@ describe("PMFlowOsProvider real change review actions", () => {
expect(result.current.state.changeItems[0]?.status).toBe("pending_review"); expect(result.current.state.changeItems[0]?.status).toBe("pending_review");
}); });
it("shows release gate and failure reminders when account notification preferences are enabled", async () => {
getAccountSettingsMock.mockResolvedValue(accountSettingsPreference({ releaseGate: true, failureAlert: true }));
getExecutionSummaryMock.mockResolvedValue(executionSummaryWithGate(releaseGate("blocked")));
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
expect(await screen.findByText("发布门禁提醒")).toBeTruthy();
expect(await screen.findByText("失败提醒")).toBeTruthy();
expect(screen.getByText("执行证据")).toBeTruthy();
expect(screen.getAllByText("测试失败").length).toBeGreaterThan(0);
});
it("keeps release evidence visible while silencing release and failure reminders when preferences are disabled", async () => {
getAccountSettingsMock.mockResolvedValue(accountSettingsPreference({ releaseGate: false, failureAlert: false }));
getExecutionSummaryMock.mockResolvedValue(executionSummaryWithGate(releaseGate("blocked")));
const { default: ReleaseGrowthPage } = await import("@/features/pmflow-os/pages/ReleaseGrowthPage");
render(<ReleaseGrowthPage />, { wrapper: wrapper() });
expect((await screen.findAllByText("执行证据")).length).toBeGreaterThan(0);
expect(screen.queryByText("发布门禁提醒")).toBeNull();
expect(screen.queryByText("失败提醒")).toBeNull();
expect((await screen.findAllByText("测试失败")).length).toBeGreaterThan(0);
});
it("still uses local demo mutation when demo mode is explicit", () => { it("still uses local demo mutation when demo mode is explicit", () => {
window.history.pushState({}, "", "/pmflow-os?demo=1"); window.history.pushState({}, "", "/pmflow-os?demo=1");
const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() }); const { result } = renderHook(() => usePMFlowOsStore(), { wrapper: wrapper() });