chore: integrate team feed capacity routing cleanup
This commit is contained in:
parent
da74902c88
commit
169bda214f
|
|
@ -97,6 +97,8 @@ frontend/ # React 前端
|
|||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。
|
||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||
- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与管理/治理入口必须登录;管理/治理入口需 OWNER/REVIEWER;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。
|
||||
- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。
|
||||
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
|
||||
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。
|
||||
- `query.answer` 路由与外部查询规划不得在运行时代码里维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 一类对象词表,也不得新增 `*_probe_is_strong / *_route_probe / query_kind(weather/news/company/market/model)` 这类二次语义拍板。`query_profile / source_policy_hint / SemanticDecision` 只能作为上游结构化输入;若证据不足,只能显式返回 `uncertain/clarify`,禁止在执行层靠对象集合、`subject_focus`、`needs_freshness`、`referenced_objects` 再次裁决 internal vs external。
|
||||
|
|
|
|||
36
CLAUDE.md
36
CLAUDE.md
|
|
@ -81,6 +81,8 @@ backend/
|
|||
- `backend/app/services/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询)
|
||||
- `backend/app/api/semantic_facts_api/`(semantic facts 治理后台接口:overview / objects / detail / reextract / backfill)
|
||||
- `backend/app/api/mcp_api/`(MCP 模板接入中心后台接口:catalog / servers / validate / confirm-client-apply)
|
||||
- `backend/app/api/ai_routing.py` + `backend/app/services/ai_routing_governance_service.py`(AI 路由与供应商产品化控制台:聚合 low/high 槽位、供应商摘要、团队策略、近期指标、只读诊断和审计;默认真源仍是 `backend/.control_plane/pmflow-routing-rules.json`,团队策略只保存到 `integrations(type='ai_team_policy')`,不得直接改 runtime 路由;作为团队级 AI 运营中心核心接口,在 `API_ROUTE_PROFILE=lean` 下也必须加载)
|
||||
- `backend/app/api/model_routing.py`(AI 运营中心“高级路由详情”兼容入口,`API_ROUTE_PROFILE=lean` 下也必须加载)
|
||||
- `backend/app/services/llm_service/`(显式 `routing_meta` 协议)
|
||||
- `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调;`mixin_receive_routing.py` 的 `qa_direct` 现已支持 recommendation ask 直出推荐正文,并把原始 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 这类内部/英文术语压成单行中文来源说明)
|
||||
- `backend/app/services/signal/`(信号接入、编排、planner、repair)
|
||||
|
|
@ -97,7 +99,7 @@ frontend/
|
|||
│ ├── api/ # 接口封装
|
||||
│ ├── features/ # 新产品域与原型状态机(如 PMFlow OS / 个人空间 / 新项目启动台)
|
||||
│ ├── pages/ # 页面级组件(已目录化)
|
||||
│ ├── components/ # 通用组件与 SSTC/Inbox 共享组件
|
||||
│ ├── components/ # 通用组件与 SSTC 等共享组件
|
||||
│ ├── hooks/ # 自定义 hooks
|
||||
│ ├── lib/ # 通用工具
|
||||
│ ├── stores/ # 状态管理
|
||||
|
|
@ -109,32 +111,34 @@ 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/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary,承载继续推进、待确认、AI 正在推进、草案箱和最近项目入口)
|
||||
- `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏;`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、`/pmflow-os`、`/change-review`、`/structured-extraction`、`/memory-writeback-result` 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口;管理/治理类页面统一走 OWNER/REVIEWER 权限边界)
|
||||
- `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`(前端身份边界、产品化 API 错误分类与登录续接 pending intent 真源;用户可见 UI 禁止直接展示 Axios/FastAPI 原始 401/403/5xx 文案)
|
||||
- `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary / execution-capabilities,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,承载执行准备度、继续推进、待确认、AI 正在推进、草案箱、缺项目归属补全和最近项目入口)
|
||||
- `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机)
|
||||
- `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=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Team reviewer routing、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端,显式 `?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/P8 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、execution handoff/progress writeback、release validation run、release feedback ingest、Team reviewer routing、recommendation change-set draft 与 recommendation group change-set draft;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / FeedbackIngest / IterationRecommendation` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 合同,生成建议审查人/原因/来源/风险/跳转 URL,并投影 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;`POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 支持 request_review/approve/request_changes/reject,复用 `PMFlowApproval` 审计,不自动 commit;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed)
|
||||
- `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed)
|
||||
- `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed);`backend/app/api/legacy_ai_steward_api.py` 仅保留旧文本创建接口的 410 迁移响应,并在 lean profile 下始终挂载
|
||||
- `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/pages/ai-steward/`(历史 AI Steward 页面迁移占位;`/ai-steward` 只做 legacy redirect,不再作为产品入口)
|
||||
- `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示)
|
||||
- `frontend/src/pages/ai-config/mcp-config/`(AI Config -> Governance Tools 下的 MCP 模板接入中心)
|
||||
- `frontend/src/pages/query-route-governance/`(AI Config -> Governance Tools 下的 route/context 治理页)
|
||||
- `frontend/src/pages/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页)
|
||||
- `frontend/src/pages/ai-config/AIConfigPage.tsx` + `frontend/src/pages/ai-config/constants.ts`(团队级 AI 运营中心;`/ai-config` 技术路由保留,用户可见命名为“AI 运营中心”,首页按策略 / 连接 / 治理 / 高级分组组织入口)
|
||||
- `frontend/src/pages/ai-routing/` + `frontend/src/api/aiRouting.ts`(AI 运营中心 -> 团队 AI 策略与 AI 路由/供应商;负责人看业务策略,管理员看供应商连接、快速/深度槽位、诊断和高级详情,成员无独立入口)
|
||||
- `frontend/src/pages/ai-config/mcp-config/`(AI 运营中心 -> MCP 模板接入中心)
|
||||
- `frontend/src/pages/query-route-governance/`(AI 运营中心 -> route/context 治理页)
|
||||
- `frontend/src/pages/semantic-facts-board/`(AI 运营中心 -> semantic facts 治理页)
|
||||
- `frontend/src/components/layout/LazyBoundary.tsx`(统一 route/page-section/dialog 级 loading skeleton)
|
||||
- `frontend/src/components/login/`(公开登录页组件目录;`LoginExperience.tsx` 负责双栏登录壳,`LoginWorkflowVisual.tsx` 负责左侧场景,`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何,`login.css` 收口登录页专属样式)
|
||||
- `frontend/src/api/queryRouteGovernance.ts`(route/context 治理页前端 API)
|
||||
- `frontend/src/pages/scheduler-center/`(Mounted AI Console)
|
||||
- `frontend/src/components/sstc/`(SSTC 工作流组件)
|
||||
- `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`(图表按页面内二级 chunk 拆分后的入口)
|
||||
- 页面目录化、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=<id>`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。
|
||||
- `frontend/src/features/personal-space/` 是空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 并生成 `nextActionQueue` 我的下一步队列、继续推进、待确认、review attention、发布关注、发布后反馈关注、下一轮迭代草稿关注、AI 正在推进、草案箱、最近项目摘要;Space P6 的下一步队列按高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后反馈/收入/合规信号排序;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,按高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning、普通 pending review 排序进入“我的下一步”,点击只跳 `/change-review?projectId=<id>&changeSetId=<id>`、已有 `/team-layer?projectId=<id>`、`/execution-bridge?projectId=<id>` 或 `/release-growth?projectId=<id>`;Space P3 的发布关注优先消费 `truth-state.releases` 的 release validation 投影,没有验证投影时才回落 `execution-summary.releaseGate`;Space P4/P5 的发布后反馈/下一轮草稿关注只读消费 `execution-summary.releaseSignals`;不在 Space 写 Project Memory、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ComplianceCheckRun、ChangeSet 状态或发布执行,不虚构 reviewer、审查意见或待审 ChangeSet。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。
|
||||
- 页面目录化、Mounted AI Console、AI Steward QA 组件与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档;旧独立收件页已退场,待处理能力收敛到 `/space`、`/change-review`、`/team-layer` 与 `/me`。
|
||||
- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;`/ai-steward` 只保留迁移重定向,`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;AI 运营中心页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。
|
||||
- `frontend/src/features/project-launch/` 是新项目启动台独立产品域,也是新建项目唯一用户入口:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,真实模式必须登录并调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=<id>`;旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误,不得绕过草案确认直接写正式对象;`mockData.ts` 只在显式 `?demo=1` 或 `?dataSource=demo` 匿名演示模式下使用。401/无 token 不得展示 `Request failed with status code 401` 或归类为网络异常,必须保存输入到 pending intent 并提示重新登录后继续。`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/` 是登录后的空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary`、`execution-capabilities`,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,生成 `nextActionQueue` 我的下一步队列、执行准备度、我的今日容量、继续推进、待确认、review attention、发布关注、发布后反馈关注、下一轮迭代草稿关注、AI 正在推进、草案箱、缺项目归属补全、最近项目摘要;未登录访问 `/space` 必须跳登录,不展示个人空间语义。Space P6 的下一步队列按高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后反馈/收入/合规信号排序;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,按高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning、普通 pending review 排序进入“我的下一步”;旧 `missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口,不直接写项目真相层。动作只跳 `/change-review?projectId=<id>&changeSetId=<id>`、已有 `/team-layer?projectId=<id>`、`/execution-bridge?projectId=<id>`、`/release-growth?projectId=<id>` 或复用旧工作项决策 API;执行准备度只读展示工具连接、待确认、待审查、待验收和阻塞原因,今日容量只读展示有效容量、人工触点、剩余容量、动态建议结构、系统解释和 AI 预处理可释放时间,不写项目业务对象;Space P3/P4/P5 仍只读消费 release validation 与 releaseSignals;不在 Space 写 Project Memory、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ComplianceCheckRun、ChangeSet 状态、容量 override 或发布执行,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力或容量计划。`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 回答深度、证据展示偏好和四类通知偏好合同;当前已有账号同步 API fallback、本地 UI 通知消费模型、Release/Growth 偏好 helper、post-release / next change review 偏好 helper、`getAccountReminderConsumption(settings, event)` 提醒强度消费合同,以及 `getReviewDecisionNotificationPreference(settings, event)` review decision 偏好合同;支持 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`;`releaseGate / failureAlert / pendingConfirmation / executionWriteback` 只影响页面内提醒表达强度和证据展开方式,不代表真实邮件、桌面推送或系统通知中心已接入,也不得隐藏真实业务证据、reviewerRouting、approval 或审计状态,不改 OAuth/JWT/auth core;`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 仅做薄入口并复用既有退出登录 bottom sheet。
|
||||
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft API,成功后 refetch truth-state / execution-summary;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ChangeReviewPage` 真实模式可请求审查、通过、要求修改或拒绝当前 ChangeSet,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage` 真实模式读取 `state.reviewerRouting` 展示 ready/empty/warning、待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、审查状态、审查意见和请求/决策时间,动作跳转 `/change-review?projectId=<id>&changeSetId=<id>`;`ExecutionBridgePage` 已支持创建 handoff、运行端领取确认和执行进度回写;`ReleaseGrowthPage` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,可创建 release validation run、摄取发布后 feedback、展示 recommendation quality grouping、从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,并消费 Account notification preference 静默强调型提醒,但不隐藏真实证据、审计状态或发布后信号;静态发布/增长原型仅保留在显式 demo 模式;后端只记录 review decision/progress/release gate/release validation/release feedback/recommendation draft 元数据,不执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`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/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft、同组 iteration recommendation 批量 -> ChangeSet draft、capacity-plan 与 capacity override simulate/apply API,成功后 refetch truth-state / execution-summary / capacity plan;显式 `?demo=1` 才启用本地演示状态机。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ChangeReviewPage` 真实模式可请求审查、通过、要求修改或拒绝当前 ChangeSet,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage` 真实模式读取 `state.reviewerRouting`、`execution-capabilities` 与 `capacity-plan`,展示待审 ChangeSet、建议 reviewer、审查状态、执行能力、个人执行准备度、工具准备度、团队今日容量、动态基线、瓶颈、AI 预处理建议、负责人模拟/应用容量 override 与调整记录;capacity override 只写审计,不自动分派、不自动审查、不写 ChangeSet;`ExecutionBridgePage` 已支持创建 handoff、运行端领取确认和执行进度回写;`ReleaseGrowthPage` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`;静态发布/增长原型仅保留在显式 demo 模式;后端只记录 review decision/progress/release gate/release validation/release feedback/recommendation draft/capacity override 元数据,不执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit、自动任务分配或正式任务创建;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。
|
||||
- `ProjectTruthState.feed` 现在是 Team Feed 只读动态流,由 `backend/app/services/pmflow_os_projection_service.py` 聚合 review request、review decision、ChangeSet 状态、execution progress 与 release gate 事件;`TeamLayerPage` 复用 `TeamFeedPanel` 展示 `state.feed`,只做摘要、空态和跳转,不写审批、不写 ChangeSet、不写通知,也不在真实模式用 mock 动态补齐。
|
||||
- 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。
|
||||
|
||||
|
|
@ -245,7 +249,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。
|
||||
- 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。
|
||||
- World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。
|
||||
- `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
||||
- `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
||||
|
||||
## 12. 技术债务清单
|
||||
- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义
|
||||
|
|
@ -258,7 +262,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链)
|
||||
- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
|
||||
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
||||
- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / handoff_mode / can_enter_plan / recommended_next_step`;前端 `FloatingQAChat` 会在即时回答与历史回放两条路径都接入这批字段,`QAConversationStatusBar` 与 `QAResponseMetaCard` 显式展示当前对象、来源模式、仓库/执行通道、项目画像、分析结果、外部推荐状态与下一步;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary` 到 Inbox 展开区,回归由 `test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py`、`FloatingQAChat.test.tsx`、`QAResponseMetaCard.test.tsx`、`InboxListItem.test.tsx` 锁定
|
||||
- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / handoff_mode / can_enter_plan / recommended_next_step`;前端 `FloatingQAChat` 会在即时回答与历史回放两条路径都接入这批字段,`QAConversationStatusBar` 与 `QAResponseMetaCard` 显式展示当前对象、来源模式、仓库/执行通道、项目画像、分析结果、外部推荐状态与下一步;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary`,旧独立收件页已退场,相关待处理状态由 Space 兼容读模型消费
|
||||
- [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
|
||||
- [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定
|
||||
- [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
"""add human capacity overrides
|
||||
|
||||
Revision ID: 20260427_add_human_capacity_overrides
|
||||
Revises: 20260426_add_pmflow_change_review_tables
|
||||
Create Date: 2026-04-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260427_add_human_capacity_overrides"
|
||||
down_revision = "20260426_add_pmflow_change_review_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "human_capacity_overrides"):
|
||||
op.create_table(
|
||||
"human_capacity_overrides",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("override_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("added_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("overtime_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("max_planned_utilization", sa.Float(), nullable=True),
|
||||
sa.Column("work_type_bias", json_type, nullable=False),
|
||||
sa.Column("high_risk_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_human_capacity_overrides_tenant_id", "human_capacity_overrides", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_project_date", "human_capacity_overrides", ["project_id", "date"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_user_date", "human_capacity_overrides", ["user_id", "date"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_created_by", "human_capacity_overrides", ["created_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "human_capacity_overrides"):
|
||||
op.drop_index("ix_human_capacity_overrides_created_by", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_user_date", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_project_date", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_tenant_id", table_name="human_capacity_overrides")
|
||||
op.drop_table("human_capacity_overrides")
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
"""AI routing and provider governance API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import error_response, success_response
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..services.ai_routing_governance_service import AIRoutingGovernanceService
|
||||
from ..services.auth import can_approve_ai_routing_change, can_operate_ai_routing, can_view_ai_policy
|
||||
from ..services.operation_log_service import log_operation
|
||||
|
||||
router = APIRouter(prefix="/ai-routing", tags=["ai_routing"])
|
||||
|
||||
|
||||
class TeamPolicySceneUpdate(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=80)
|
||||
policy: str = Field(min_length=1, max_length=300)
|
||||
mode: Optional[str] = Field(default=None, max_length=80)
|
||||
human_confirmation: bool = True
|
||||
protected: bool = True
|
||||
|
||||
|
||||
class TeamPolicyUpdateRequest(BaseModel):
|
||||
strategy: Literal["quality_first", "balanced", "speed_first"] = "balanced"
|
||||
scenes: list[TeamPolicySceneUpdate] = Field(default_factory=list)
|
||||
automation_boundary: Optional[str] = Field(default=None, max_length=300)
|
||||
budget_guard: dict[str, Any] = Field(default_factory=dict)
|
||||
quality_targets: list[str] = Field(default_factory=list, max_length=8)
|
||||
|
||||
|
||||
def _forbidden(message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content=error_response(403, message))
|
||||
|
||||
|
||||
def _service(db: AsyncSession, tenant_id: int) -> AIRoutingGovernanceService:
|
||||
return AIRoutingGovernanceService(db, tenant_id=int(tenant_id))
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_ai_routing_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_view_ai_policy(str(current_user.role or "")):
|
||||
return _forbidden("仅团队负责人可查看 AI 路由概览")
|
||||
data = await _service(db, tenant_id).get_overview()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/team-policy")
|
||||
async def get_ai_team_policy(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_view_ai_policy(str(current_user.role or "")):
|
||||
return _forbidden("仅团队负责人可查看团队 AI 策略")
|
||||
data = await _service(db, tenant_id).get_team_policy()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.put("/team-policy")
|
||||
async def update_ai_team_policy(
|
||||
body: TeamPolicyUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_approve_ai_routing_change(str(current_user.role or "")):
|
||||
return _forbidden("仅 OWNER 可保存团队 AI 策略")
|
||||
service = _service(db, tenant_id)
|
||||
before = await service.get_team_policy()
|
||||
after = await service.update_team_policy(
|
||||
payload=body.model_dump(mode="json"),
|
||||
updated_by=int(current_user.id),
|
||||
)
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_routing_policy",
|
||||
entity_id="team-policy",
|
||||
action="update",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="更新团队 AI 策略",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.get("/admin-console")
|
||||
async def get_ai_routing_admin_console(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_view_ai_policy(str(current_user.role or "")):
|
||||
return _forbidden("仅团队负责人可查看 AI 路由控制台")
|
||||
data = await _service(db, tenant_id).get_admin_console()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/diagnostics/run")
|
||||
async def run_ai_routing_diagnostics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_operate_ai_routing(str(current_user.role or "")):
|
||||
return _forbidden("仅 OWNER / REVIEWER 可执行 AI 路由诊断")
|
||||
data = await _service(db, tenant_id).run_diagnostics()
|
||||
return success_response(data)
|
||||
|
|
@ -1,46 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse
|
||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest
|
||||
|
||||
@router.post("/project-from-text", response_model=ApiResponse[ProjectFromTextResponse])
|
||||
async def create_project_from_text(
|
||||
request: ProjectFromTextRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""
|
||||
零配置项目创建 - 从一句话生成完整项目
|
||||
|
||||
示例输入:
|
||||
```
|
||||
我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线
|
||||
```
|
||||
|
||||
AI 将自动生成:
|
||||
- 完整的项目配置
|
||||
- 竞品分析信号
|
||||
- 项目宪章
|
||||
- 任务链(含依赖关系)
|
||||
- 风险预警规则
|
||||
"""
|
||||
try:
|
||||
service = get_ai_steward_service(db)
|
||||
|
||||
result = await service.create_project_from_natural_language(
|
||||
input_text=request.input_text,
|
||||
creator_id=int(current_user.id),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
return success_response(ProjectFromTextResponse(**result).model_dump())
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"项目创建失败: {str(e)}"),
|
||||
)
|
||||
@router.post("/project-from-text")
|
||||
async def create_project_from_text():
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
|
||||
)
|
||||
|
||||
@router.post("/signal/capture-feishu")
|
||||
@audited_write(caller="ai")
|
||||
|
|
|
|||
|
|
@ -2,46 +2,6 @@ from __future__ import annotations
|
|||
|
||||
from .shared import *
|
||||
|
||||
class ProjectFromTextRequest(BaseModel):
|
||||
"""零配置项目创建请求"""
|
||||
input_text: str = Field(
|
||||
...,
|
||||
description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线",
|
||||
min_length=10,
|
||||
max_length=500
|
||||
)
|
||||
|
||||
class ProjectFromTextSignalSource(BaseModel):
|
||||
registry_id: int | str | None = None
|
||||
id: int | str | None = None
|
||||
project_id: int | str | None = None
|
||||
signal_id: int | str | None = None
|
||||
source_kind: str
|
||||
source_name: str
|
||||
source_identifier: str
|
||||
display_source_kind: Optional[str] = None
|
||||
technical_identifier: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
requested_via: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
onboarding_status: Optional[str] = None
|
||||
section: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
origin: Optional[str] = None
|
||||
raw_context: Optional[Dict[str, Any]] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class ProjectFromTextResponse(BaseModel):
|
||||
"""零配置项目创建响应"""
|
||||
project: Dict[str, Any]
|
||||
signal: Dict[str, Any]
|
||||
charter: Dict[str, Any]
|
||||
tasks: List[Dict[str, Any]]
|
||||
risks: List[Dict[str, Any]]
|
||||
signal_sources: List[ProjectFromTextSignalSource] = Field(default_factory=list)
|
||||
analysis: Dict[str, Any]
|
||||
|
||||
class CognitiveConflictResponse(BaseModel):
|
||||
"""认知冲突响应"""
|
||||
id: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ..core.response import error_response
|
||||
|
||||
router = APIRouter(prefix="/ai-steward", tags=["AI Steward Legacy"])
|
||||
|
||||
|
||||
@router.post("/project-from-text")
|
||||
async def create_project_from_text_legacy_gone():
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
|
||||
)
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import date as Date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -10,6 +12,7 @@ from ...core.response import error_response, success_response
|
|||
from ...dependencies import get_current_user
|
||||
from ...models import Project, User, UserProject
|
||||
from ...schemas.pmflow_os import (
|
||||
PMFlowCapacityOverridePayload,
|
||||
PMFlowChangeItemDeferRequest,
|
||||
PMFlowChangeItemEditRequest,
|
||||
PMFlowChangeSetCommitRequest,
|
||||
|
|
@ -25,8 +28,10 @@ from ...schemas.pmflow_os import (
|
|||
PMFlowThreadMessageCreateRequest,
|
||||
)
|
||||
from ...services.auth import has_any_role
|
||||
from ...services.adaptive_capacity_service import AdaptiveCapacityService
|
||||
from ...services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
|
||||
from ...services.pmflow_os_projection_service import PMFlowOSProjectionService
|
||||
from ...services.team_execution_capability_service import TeamExecutionCapabilityService
|
||||
|
||||
router = APIRouter(prefix="/pmflow-os", tags=["pmflow-os"])
|
||||
|
||||
|
|
@ -62,6 +67,20 @@ async def _ensure_project_member_or_manager(
|
|||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
|
||||
|
||||
def _ensure_capacity_override_manager(current_user: User) -> None:
|
||||
if not has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
|
||||
|
||||
def _parse_capacity_date(value: str | None) -> Date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return Date.fromisoformat(str(value)[:10])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="invalid_date") from exc
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/truth-state")
|
||||
async def get_pmflow_os_truth_state(
|
||||
project_id: int,
|
||||
|
|
@ -292,6 +311,137 @@ async def get_pmflow_os_execution_summary(
|
|||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/execution-capabilities")
|
||||
async def get_pmflow_os_execution_capabilities(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
service = TeamExecutionCapabilityService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
payload = await service.get_snapshot(
|
||||
project_id=int(project_id),
|
||||
current_user_id=int(current_user.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "project_not_found":
|
||||
raise HTTPException(status_code=404, detail="project_not_found") from exc
|
||||
raise
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/capacity-plan")
|
||||
async def get_pmflow_os_capacity_plan(
|
||||
project_id: int,
|
||||
plan_date: str | None = Query(default=None, alias="date"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
payload = await service.get_plan(
|
||||
project_id=int(project_id),
|
||||
current_user_id=int(current_user.id),
|
||||
plan_date=_parse_capacity_date(plan_date),
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "project_not_found":
|
||||
raise HTTPException(status_code=404, detail="project_not_found") from exc
|
||||
raise
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/capacity-overrides/simulate")
|
||||
async def simulate_pmflow_os_capacity_override(
|
||||
project_id: int,
|
||||
body: PMFlowCapacityOverridePayload,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
_ensure_capacity_override_manager(current_user)
|
||||
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
payload = await service.simulate_override(
|
||||
project_id=int(project_id),
|
||||
current_user_id=int(current_user.id),
|
||||
payload=body,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "capacity_member_not_found":
|
||||
raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc
|
||||
raise
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/capacity-overrides")
|
||||
async def create_pmflow_os_capacity_override(
|
||||
project_id: int,
|
||||
body: PMFlowCapacityOverridePayload,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
_ensure_capacity_override_manager(current_user)
|
||||
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
payload = await service.apply_override(
|
||||
project_id=int(project_id),
|
||||
current_user_id=int(current_user.id),
|
||||
payload=body,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "capacity_member_not_found":
|
||||
raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc
|
||||
raise
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/capacity-overrides")
|
||||
async def list_pmflow_os_capacity_overrides(
|
||||
project_id: int,
|
||||
plan_date: str | None = Query(default=None, alias="date"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
|
||||
payload = await service.list_overrides(project_id=int(project_id), plan_date=_parse_capacity_date(plan_date))
|
||||
return success_response([item.model_dump(mode="json") for item in payload])
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/execution-handoffs")
|
||||
async def create_pmflow_os_execution_handoff(
|
||||
project_id: int,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ CORE_ROUTE_MODULES = (
|
|||
"task_briefs",
|
||||
"pmflow_os_api",
|
||||
"pmflow_launch_api",
|
||||
"legacy_ai_steward_api",
|
||||
"account_settings_api",
|
||||
"ai_routing",
|
||||
"model_routing",
|
||||
)
|
||||
|
||||
OPTIONAL_ROUTE_GROUPS = {
|
||||
|
|
@ -45,7 +48,6 @@ OPTIONAL_ROUTE_GROUPS = {
|
|||
"ai_logs_api",
|
||||
"prompt_profiles",
|
||||
"signal_sources_api",
|
||||
"model_routing",
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from .user_project import UserProject
|
|||
from .skill import SkillTag
|
||||
from .user_skill_binding import UserSkillBinding
|
||||
from .user_workload import UserWorkload
|
||||
from .human_capacity_override import HumanCapacityOverride
|
||||
from .signal import Signal
|
||||
from .signal_edit import SignalEdit
|
||||
from .signal_feedback import SignalFeedback
|
||||
|
|
@ -122,6 +123,7 @@ __all__ = [
|
|||
"SkillTag",
|
||||
"UserSkillBinding",
|
||||
"UserWorkload",
|
||||
"HumanCapacityOverride",
|
||||
"Signal",
|
||||
"SignalEdit",
|
||||
"SignalFeedback",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""Human capacity override audit model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Date, Float, ForeignKey, Integer, JSON, String, Text, TIMESTAMP
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class HumanCapacityOverride(Base):
|
||||
__tablename__ = "human_capacity_overrides"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date: Mapped[Any] = mapped_column(Date, nullable=False, index=True)
|
||||
override_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
added_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
overtime_type: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
max_planned_utilization: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
work_type_bias: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
high_risk_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
expires_at: Mapped[Any] = mapped_column(TIMESTAMP, nullable=False)
|
||||
created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
|
@ -40,6 +40,28 @@ PMFlowReviewerConfidence = Literal["low", "medium", "high"]
|
|||
PMFlowFallbackReviewerRole = Literal["OWNER", "REVIEWER", "MEMBER"]
|
||||
PMFlowReviewDecisionAction = Literal["request_review", "approve", "request_changes", "reject"]
|
||||
PMFlowApprovalState = Literal["not_requested", "requested", "approved", "changes_requested", "rejected"]
|
||||
PMFlowExecutionCapabilityMode = Literal[
|
||||
"ai_direct",
|
||||
"ai_with_user_acceptance",
|
||||
"ai_with_technical_review",
|
||||
"needs_human_judgement",
|
||||
"blocked",
|
||||
]
|
||||
PMFlowExecutionCapabilityStatus = Literal["ready", "partial", "blocked", "unknown"]
|
||||
PMFlowHumanWorkType = Literal["focus", "review", "acceptance", "coordination"]
|
||||
PMFlowCapacityStatus = Literal["normal", "attention", "near_full", "overloaded", "blocked", "manually_extended"]
|
||||
PMFlowCapacityFitStatus = Literal["recommended", "allowed_with_warning", "not_recommended", "blocked"]
|
||||
PMFlowCapacityOverrideType = Literal["extend_minutes", "overtime", "lock_capacity", "work_type_bias", "high_risk_exception"]
|
||||
PMFlowOvertimeType = Literal["general", "focus", "review", "release_guard"]
|
||||
PMFlowHumanTouchSourceType = Literal[
|
||||
"changeset_confirmation",
|
||||
"review",
|
||||
"acceptance",
|
||||
"release_decision",
|
||||
"context_completion",
|
||||
"ai_failure_recovery",
|
||||
]
|
||||
PMFlowHumanTouchBindingType = Literal["hard_bound", "role_bound", "ai_shiftable"]
|
||||
PMFlowObjectType = Literal[
|
||||
"Requirement",
|
||||
"Decision",
|
||||
|
|
@ -392,3 +414,185 @@ class ExecutionSummaryProjection(BaseModel):
|
|||
recentProgressEvents: list[dict[str, Any]] = Field(default_factory=list)
|
||||
recentDeliveries: list[dict[str, Any]] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PMFlowExecutionModeCapability(BaseModel):
|
||||
mode: PMFlowExecutionCapabilityMode
|
||||
label: str
|
||||
status: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
reason: str
|
||||
targetHref: Optional[str] = None
|
||||
|
||||
|
||||
class PMFlowToolReadinessItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
status: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
summary: str
|
||||
href: Optional[str] = None
|
||||
|
||||
|
||||
class PMFlowPersonalExecutionReadiness(BaseModel):
|
||||
status: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
toolReadiness: list[PMFlowToolReadinessItem] = Field(default_factory=list)
|
||||
waitingForUser: dict[str, int] = Field(default_factory=dict)
|
||||
blockers: list[str] = Field(default_factory=list)
|
||||
recommendedNextStep: str = "回到项目主流程查看下一步。"
|
||||
targetHref: str
|
||||
|
||||
|
||||
class PMFlowTeamCapacitySignal(BaseModel):
|
||||
status: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
label: str
|
||||
workloadRatio: float = 0.0
|
||||
pendingReviewCount: int = 0
|
||||
activeExecutionCount: int = 0
|
||||
|
||||
|
||||
class PMFlowTeamContextSignal(BaseModel):
|
||||
status: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
label: str
|
||||
evidenceCount: int = 0
|
||||
recentContributionCount: int = 0
|
||||
|
||||
|
||||
class PMFlowTeamToolSignal(BaseModel):
|
||||
status: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
label: str
|
||||
connectedTools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PMFlowTeamExecutionSignal(BaseModel):
|
||||
userId: str
|
||||
displayName: str
|
||||
role: str
|
||||
judgementResponsibilities: list[str] = Field(default_factory=list)
|
||||
reviewResponsibilities: list[str] = Field(default_factory=list)
|
||||
capacity: PMFlowTeamCapacitySignal
|
||||
context: PMFlowTeamContextSignal
|
||||
toolStatus: PMFlowTeamToolSignal
|
||||
referenceSkills: list[str] = Field(default_factory=list)
|
||||
recommendedModes: list[PMFlowExecutionCapabilityMode] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PMFlowWorkTypeDistribution(BaseModel):
|
||||
focus: float = 0.0
|
||||
review: float = 0.0
|
||||
acceptance: float = 0.0
|
||||
coordination: float = 0.0
|
||||
|
||||
|
||||
class PMFlowWorkTypeMinutes(BaseModel):
|
||||
focus: int = 0
|
||||
review: int = 0
|
||||
acceptance: int = 0
|
||||
coordination: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class PMFlowHumanTouchEstimate(BaseModel):
|
||||
sourceType: PMFlowHumanTouchSourceType
|
||||
sourceId: str
|
||||
targetHref: str
|
||||
workType: PMFlowHumanWorkType
|
||||
estimatedMinutes: int
|
||||
riskLevel: PMFlowRiskLevel = "low"
|
||||
urgency: str = "normal"
|
||||
bindingType: PMFlowHumanTouchBindingType = "role_bound"
|
||||
assignedUserId: Optional[str] = None
|
||||
roleBinding: Optional[str] = None
|
||||
contextReadiness: PMFlowExecutionCapabilityStatus = "unknown"
|
||||
aiReductionPotentialMinutes: int = 0
|
||||
title: str
|
||||
summary: str
|
||||
|
||||
|
||||
class PMFlowCapacityFit(BaseModel):
|
||||
workType: PMFlowHumanWorkType
|
||||
label: str
|
||||
status: PMFlowCapacityFitStatus
|
||||
estimatedMinutes: int
|
||||
afterTotalUtilization: float
|
||||
afterTypeShare: float
|
||||
reason: str
|
||||
|
||||
|
||||
class PMFlowCapacityOverridePayload(BaseModel):
|
||||
userId: str
|
||||
date: Optional[str] = None
|
||||
overrideType: PMFlowCapacityOverrideType
|
||||
addedMinutes: int = Field(default=0, ge=0, le=720)
|
||||
overtimeType: Optional[PMFlowOvertimeType] = None
|
||||
maxPlannedUtilization: Optional[float] = Field(default=None, ge=0.5, le=1.25)
|
||||
workTypeBias: dict[str, float] = Field(default_factory=dict)
|
||||
highRiskLimit: Optional[int] = Field(default=None, ge=0, le=20)
|
||||
reason: str = Field(..., min_length=2, max_length=500)
|
||||
expiresAt: Optional[str] = None
|
||||
|
||||
|
||||
class PMFlowCapacityOverrideRecord(BaseModel):
|
||||
id: str
|
||||
userId: str
|
||||
date: str
|
||||
overrideType: PMFlowCapacityOverrideType
|
||||
addedMinutes: int = 0
|
||||
overtimeType: Optional[PMFlowOvertimeType] = None
|
||||
maxPlannedUtilization: Optional[float] = None
|
||||
workTypeBias: dict[str, float] = Field(default_factory=dict)
|
||||
highRiskLimit: Optional[int] = None
|
||||
reason: str
|
||||
expiresAt: str
|
||||
createdBy: str
|
||||
createdAt: Optional[str] = None
|
||||
|
||||
|
||||
class PMFlowMemberCapacityPlan(BaseModel):
|
||||
userId: str
|
||||
displayName: str
|
||||
role: str
|
||||
baseAvailableMinutes: int = 480
|
||||
calendarBlockedMinutes: int = 0
|
||||
effectiveMinutes: int = 480
|
||||
reservedBufferMinutes: int = 72
|
||||
maxPlannedUtilization: float = 0.85
|
||||
suggestedBaseline: PMFlowWorkTypeDistribution = Field(default_factory=PMFlowWorkTypeDistribution)
|
||||
currentMix: PMFlowWorkTypeDistribution = Field(default_factory=PMFlowWorkTypeDistribution)
|
||||
plannedTouchMinutes: PMFlowWorkTypeMinutes = Field(default_factory=PMFlowWorkTypeMinutes)
|
||||
capacityStatus: PMFlowCapacityStatus = "normal"
|
||||
remainingMinutes: int = 0
|
||||
bottleneckType: Optional[PMFlowHumanWorkType] = None
|
||||
fatigueRisk: dict[str, str] = Field(default_factory=dict)
|
||||
riskDensity: dict[str, int] = Field(default_factory=dict)
|
||||
canTakeMore: list[PMFlowCapacityFit] = Field(default_factory=list)
|
||||
manualOverride: Optional[PMFlowCapacityOverrideRecord] = None
|
||||
explanations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PMFlowDailyCapacityPlan(BaseModel):
|
||||
projectId: str
|
||||
date: str
|
||||
generatedAt: str
|
||||
teamDemandMix: PMFlowWorkTypeDistribution = Field(default_factory=PMFlowWorkTypeDistribution)
|
||||
teamEffectiveMinutes: int = 0
|
||||
teamPlannedTouchMinutes: PMFlowWorkTypeMinutes = Field(default_factory=PMFlowWorkTypeMinutes)
|
||||
memberPlans: list[PMFlowMemberCapacityPlan] = Field(default_factory=list)
|
||||
humanTouches: list[PMFlowHumanTouchEstimate] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
recommendations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PMFlowCapacityOverrideSimulation(BaseModel):
|
||||
beforePlan: PMFlowMemberCapacityPlan
|
||||
afterPlan: PMFlowMemberCapacityPlan
|
||||
impactSummary: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PMFlowExecutionCapabilitySnapshot(BaseModel):
|
||||
projectId: str
|
||||
executionModes: list[PMFlowExecutionModeCapability] = Field(default_factory=list)
|
||||
personalReadiness: PMFlowPersonalExecutionReadiness
|
||||
teamSignals: list[PMFlowTeamExecutionSignal] = Field(default_factory=list)
|
||||
adaptiveCapacitySummary: Optional[PMFlowDailyCapacityPlan] = None
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,614 @@
|
|||
"""Adaptive human capacity planning for PMFlow Team Layer and Space."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import DevWorkspaceBinding, HumanCapacityOverride, Project, User, UserProject, UserWorkload
|
||||
from ..schemas.pmflow_os import (
|
||||
PMFlowCapacityFit,
|
||||
PMFlowCapacityOverridePayload,
|
||||
PMFlowCapacityOverrideRecord,
|
||||
PMFlowCapacityOverrideSimulation,
|
||||
PMFlowDailyCapacityPlan,
|
||||
PMFlowHumanTouchEstimate,
|
||||
PMFlowHumanWorkType,
|
||||
PMFlowMemberCapacityPlan,
|
||||
PMFlowWorkTypeDistribution,
|
||||
PMFlowWorkTypeMinutes,
|
||||
)
|
||||
from ..utils.timezone import now_cn_naive
|
||||
from .pmflow_os_projection_service import PMFlowOSProjectionService
|
||||
|
||||
|
||||
WORK_TYPES: tuple[PMFlowHumanWorkType, ...] = ("focus", "review", "acceptance", "coordination")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MemberContext:
|
||||
user: User
|
||||
tools: list[str]
|
||||
workload_ratio: float
|
||||
|
||||
|
||||
class AdaptiveCapacityService:
|
||||
"""Computes daily capacity plans from current task/review/execution facts plus overrides."""
|
||||
|
||||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
|
||||
async def get_plan(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
current_user_id: int,
|
||||
plan_date: Optional[date] = None,
|
||||
extra_override: Optional[PMFlowCapacityOverridePayload] = None,
|
||||
) -> PMFlowDailyCapacityPlan:
|
||||
plan_date = plan_date or now_cn_naive().date()
|
||||
projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id)
|
||||
truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id)
|
||||
execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||||
members = await self._load_members(project_id=project_id)
|
||||
project = await self._load_project(project_id=project_id)
|
||||
member_ids = [int(ctx.user.id) for ctx in members]
|
||||
overrides = await self._load_active_overrides(project_id=project_id, plan_date=plan_date, member_ids=member_ids)
|
||||
if extra_override is not None:
|
||||
overrides[int(extra_override.userId)] = self._payload_to_record(extra_override, plan_date=plan_date, created_by=current_user_id)
|
||||
|
||||
touches = self._build_touches(
|
||||
project_id=project_id,
|
||||
owner_id=int(project.owner_id or current_user_id) if project else current_user_id,
|
||||
member_ids=member_ids,
|
||||
truth_state=truth_state,
|
||||
execution_summary=execution_summary,
|
||||
)
|
||||
team_minutes = self._sum_minutes(touches)
|
||||
team_mix = self._distribution(team_minutes)
|
||||
plans = [
|
||||
self._member_plan(
|
||||
ctx=ctx,
|
||||
project_id=project_id,
|
||||
team_mix=team_mix,
|
||||
touches=[touch for touch in touches if str(touch.assignedUserId or "") == str(ctx.user.id)],
|
||||
override=overrides.get(int(ctx.user.id)),
|
||||
)
|
||||
for ctx in members
|
||||
]
|
||||
team_effective = sum(item.effectiveMinutes for item in plans)
|
||||
warnings = list(execution_summary.warnings[:6])
|
||||
if not members:
|
||||
warnings.append("capacity_members_empty: 当前项目没有可计算人类容量的成员。")
|
||||
if not touches:
|
||||
warnings.append("capacity_touches_empty: 当前没有真实人工触点,容量计划保持空态。")
|
||||
recommendations = self._recommendations(touches=touches, plans=plans)
|
||||
return PMFlowDailyCapacityPlan(
|
||||
projectId=str(project_id),
|
||||
date=plan_date.isoformat(),
|
||||
generatedAt=now_cn_naive().isoformat(),
|
||||
teamDemandMix=team_mix,
|
||||
teamEffectiveMinutes=team_effective,
|
||||
teamPlannedTouchMinutes=team_minutes,
|
||||
memberPlans=plans,
|
||||
humanTouches=touches[:80],
|
||||
warnings=warnings[:12],
|
||||
recommendations=recommendations[:8],
|
||||
)
|
||||
|
||||
async def simulate_override(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
current_user_id: int,
|
||||
payload: PMFlowCapacityOverridePayload,
|
||||
) -> PMFlowCapacityOverrideSimulation:
|
||||
plan_date = self._parse_date(payload.date)
|
||||
before = await self.get_plan(project_id=project_id, current_user_id=current_user_id, plan_date=plan_date)
|
||||
after = await self.get_plan(
|
||||
project_id=project_id,
|
||||
current_user_id=current_user_id,
|
||||
plan_date=plan_date,
|
||||
extra_override=payload,
|
||||
)
|
||||
before_member = self._find_member_plan(before, payload.userId)
|
||||
after_member = self._find_member_plan(after, payload.userId)
|
||||
impact = [
|
||||
f"有效容量 {before_member.effectiveMinutes}min -> {after_member.effectiveMinutes}min",
|
||||
f"剩余可计划容量 {before_member.remainingMinutes}min -> {after_member.remainingMinutes}min",
|
||||
f"状态 {before_member.capacityStatus} -> {after_member.capacityStatus}",
|
||||
]
|
||||
if payload.addedMinutes:
|
||||
impact.append("加班只增加时间预算,不消除疲劳与高风险判断密度。")
|
||||
return PMFlowCapacityOverrideSimulation(beforePlan=before_member, afterPlan=after_member, impactSummary=impact, warnings=after.warnings[:6])
|
||||
|
||||
async def apply_override(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
current_user_id: int,
|
||||
payload: PMFlowCapacityOverridePayload,
|
||||
) -> PMFlowCapacityOverrideRecord:
|
||||
plan_date = self._parse_date(payload.date)
|
||||
expires_at = self._parse_expires(payload.expiresAt, plan_date=plan_date)
|
||||
record = HumanCapacityOverride(
|
||||
tenant_id=self.tenant_id,
|
||||
project_id=int(project_id),
|
||||
user_id=int(payload.userId),
|
||||
date=plan_date,
|
||||
override_type=payload.overrideType,
|
||||
added_minutes=int(payload.addedMinutes or 0),
|
||||
overtime_type=payload.overtimeType,
|
||||
max_planned_utilization=payload.maxPlannedUtilization,
|
||||
work_type_bias=dict(payload.workTypeBias or {}),
|
||||
high_risk_limit=payload.highRiskLimit,
|
||||
reason=payload.reason.strip(),
|
||||
expires_at=expires_at,
|
||||
created_by=int(current_user_id),
|
||||
)
|
||||
self.db.add(record)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(record)
|
||||
return self._override_record(record)
|
||||
|
||||
async def list_overrides(self, *, project_id: int, plan_date: Optional[date] = None) -> list[PMFlowCapacityOverrideRecord]:
|
||||
plan_date = plan_date or now_cn_naive().date()
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(HumanCapacityOverride)
|
||||
.where(
|
||||
and_(
|
||||
HumanCapacityOverride.tenant_id == self.tenant_id,
|
||||
HumanCapacityOverride.project_id == int(project_id),
|
||||
HumanCapacityOverride.date == plan_date,
|
||||
)
|
||||
)
|
||||
.order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [self._override_record(row) for row in rows]
|
||||
|
||||
async def _load_project(self, *, project_id: int) -> Optional[Project]:
|
||||
return (
|
||||
await self.db.execute(
|
||||
select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == int(project_id)))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
async def _load_members(self, *, project_id: int) -> list[_MemberContext]:
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(User)
|
||||
.join(UserProject, UserProject.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.tenant_id == self.tenant_id,
|
||||
UserProject.tenant_id == self.tenant_id,
|
||||
UserProject.project_id == int(project_id),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
.order_by(User.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
member_ids = [int(user.id) for user in rows]
|
||||
tool_map = await self._load_tools(project_id=project_id, member_ids=member_ids)
|
||||
workload_map = await self._load_workloads(member_ids=member_ids)
|
||||
return [
|
||||
_MemberContext(user=user, tools=tool_map.get(int(user.id), []), workload_ratio=workload_map.get(int(user.id), 0.0))
|
||||
for user in rows
|
||||
]
|
||||
|
||||
async def _load_tools(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]:
|
||||
if not member_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where(
|
||||
and_(
|
||||
DevWorkspaceBinding.tenant_id == self.tenant_id,
|
||||
DevWorkspaceBinding.project_id == int(project_id),
|
||||
DevWorkspaceBinding.user_id.in_(member_ids),
|
||||
DevWorkspaceBinding.status == "active",
|
||||
)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
labels = {"cursor": "Cursor", "vscode": "VS Code", "codex": "Codex"}
|
||||
tool_map: dict[int, list[str]] = {}
|
||||
for user_id, editor_type in rows:
|
||||
tool_map.setdefault(int(user_id), [])
|
||||
label = labels.get(str(editor_type or "").lower(), str(editor_type or "Local Bridge"))
|
||||
if label not in tool_map[int(user_id)]:
|
||||
tool_map[int(user_id)].append(label)
|
||||
return tool_map
|
||||
|
||||
async def _load_workloads(self, *, member_ids: list[int]) -> dict[int, float]:
|
||||
if not member_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(UserWorkload.user_id, UserWorkload.workload_ratio).where(
|
||||
and_(UserWorkload.tenant_id == self.tenant_id, UserWorkload.user_id.in_(member_ids))
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return {int(user_id): float(ratio or 0.0) for user_id, ratio in rows}
|
||||
|
||||
async def _load_active_overrides(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
plan_date: date,
|
||||
member_ids: list[int],
|
||||
) -> dict[int, PMFlowCapacityOverrideRecord]:
|
||||
if not member_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(HumanCapacityOverride)
|
||||
.where(
|
||||
and_(
|
||||
HumanCapacityOverride.tenant_id == self.tenant_id,
|
||||
HumanCapacityOverride.project_id == int(project_id),
|
||||
HumanCapacityOverride.user_id.in_(member_ids),
|
||||
HumanCapacityOverride.date == plan_date,
|
||||
HumanCapacityOverride.expires_at >= now_cn_naive(),
|
||||
)
|
||||
)
|
||||
.order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
overrides: dict[int, PMFlowCapacityOverrideRecord] = {}
|
||||
for row in rows:
|
||||
overrides.setdefault(int(row.user_id), self._override_record(row))
|
||||
return overrides
|
||||
|
||||
def _build_touches(self, *, project_id: int, owner_id: int, member_ids: list[int], truth_state: Any, execution_summary: Any) -> list[PMFlowHumanTouchEstimate]:
|
||||
touches: list[PMFlowHumanTouchEstimate] = []
|
||||
fallback_user = str(owner_id if owner_id in member_ids else (member_ids[0] if member_ids else owner_id))
|
||||
for item in getattr(truth_state, "changeItems", []) or []:
|
||||
if self._get(item, "status") != "pending_review":
|
||||
continue
|
||||
risk = self._risk(self._get(item, "riskLevel") or self._get(item, "risk_level"))
|
||||
minutes = 60 if risk == "high" else 35 if risk == "medium" else 20
|
||||
touches.append(
|
||||
PMFlowHumanTouchEstimate(
|
||||
sourceType="changeset_confirmation",
|
||||
sourceId=str(self._get(item, "id") or f"change-{len(touches)}"),
|
||||
targetHref=f"/change-review?projectId={project_id}&changeSetId={self._get(item, 'changeSetId') or ''}",
|
||||
workType="focus",
|
||||
estimatedMinutes=minutes,
|
||||
riskLevel=risk,
|
||||
urgency="urgent" if risk == "high" else "normal",
|
||||
bindingType="hard_bound",
|
||||
assignedUserId=fallback_user,
|
||||
roleBinding="OWNER",
|
||||
contextReadiness="partial",
|
||||
aiReductionPotentialMinutes=25 if risk != "low" else 10,
|
||||
title=str(self._get(item, "title") or "待确认 ChangeSet"),
|
||||
summary=str(self._get(item, "summary") or "需要人类确认 AI 提取结果。"),
|
||||
)
|
||||
)
|
||||
for routing in getattr(getattr(truth_state, "reviewerRouting", None), "items", []) or []:
|
||||
reviewer = getattr(routing, "suggestedReviewer", None)
|
||||
assigned = str(getattr(reviewer, "userId", "") or fallback_user)
|
||||
risk = self._risk(getattr(routing, "riskLevel", "low"))
|
||||
touches.append(
|
||||
PMFlowHumanTouchEstimate(
|
||||
sourceType="review",
|
||||
sourceId=str(getattr(routing, "changeSetId", "")),
|
||||
targetHref=str(getattr(routing, "targetUrl", "") or f"/change-review?projectId={project_id}"),
|
||||
workType="review",
|
||||
estimatedMinutes=75 if risk == "high" else 45 if risk == "medium" else 30,
|
||||
riskLevel=risk,
|
||||
urgency=str(getattr(routing, "priority", "normal")),
|
||||
bindingType="role_bound",
|
||||
assignedUserId=assigned,
|
||||
roleBinding=str(getattr(routing, "fallbackReviewerRole", None) or "REVIEWER"),
|
||||
contextReadiness="partial",
|
||||
aiReductionPotentialMinutes=30 if risk == "high" else 15,
|
||||
title=str(getattr(routing, "changeSetTitle", "待审 ChangeSet")),
|
||||
summary=str(getattr(routing, "reviewReason", "需要人工审查 ChangeSet。")),
|
||||
)
|
||||
)
|
||||
for idx, event in enumerate(execution_summary.recentProgressEvents or []):
|
||||
event_type = str(event.get("event_type") or "")
|
||||
if event_type not in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed", "failed", "blocked"}:
|
||||
continue
|
||||
work_type: PMFlowHumanWorkType = "coordination" if event_type in {"failed", "blocked"} else "acceptance"
|
||||
touches.append(
|
||||
PMFlowHumanTouchEstimate(
|
||||
sourceType="ai_failure_recovery" if work_type == "coordination" else "acceptance",
|
||||
sourceId=str(event.get("id") or f"event-{idx}"),
|
||||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||||
workType=work_type,
|
||||
estimatedMinutes=45 if work_type == "coordination" else 30,
|
||||
riskLevel="medium" if work_type == "coordination" else "low",
|
||||
urgency="elevated" if work_type == "coordination" else "normal",
|
||||
bindingType="ai_shiftable",
|
||||
assignedUserId=str(event.get("user_id") or fallback_user),
|
||||
roleBinding="BUILDER",
|
||||
contextReadiness="partial",
|
||||
aiReductionPotentialMinutes=20,
|
||||
title=str(event.get("summary") or "AI 执行结果待处理"),
|
||||
summary="AI 执行结果需要验收或阻塞恢复。",
|
||||
)
|
||||
)
|
||||
if not execution_summary.repoBindings:
|
||||
touches.append(self._coordination_touch(project_id, fallback_user, "缺少 repo 绑定", "未接入 repo,AI 执行包缺少真实代码上下文。", 30))
|
||||
if not execution_summary.promptPackage:
|
||||
touches.append(self._coordination_touch(project_id, fallback_user, "缺少执行包", "尚未生成可交给 Cursor / VS Code / Codex 的执行包。", 20))
|
||||
return touches
|
||||
|
||||
def _coordination_touch(self, project_id: int, assigned_user_id: str, title: str, summary: str, minutes: int) -> PMFlowHumanTouchEstimate:
|
||||
return PMFlowHumanTouchEstimate(
|
||||
sourceType="context_completion",
|
||||
sourceId=title,
|
||||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||||
workType="coordination",
|
||||
estimatedMinutes=minutes,
|
||||
riskLevel="medium",
|
||||
urgency="normal",
|
||||
bindingType="ai_shiftable",
|
||||
assignedUserId=assigned_user_id,
|
||||
roleBinding="BUILDER",
|
||||
contextReadiness="blocked",
|
||||
aiReductionPotentialMinutes=10,
|
||||
title=title,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
def _member_plan(
|
||||
self,
|
||||
*,
|
||||
ctx: _MemberContext,
|
||||
project_id: int,
|
||||
team_mix: PMFlowWorkTypeDistribution,
|
||||
touches: list[PMFlowHumanTouchEstimate],
|
||||
override: Optional[PMFlowCapacityOverrideRecord],
|
||||
) -> PMFlowMemberCapacityPlan:
|
||||
base_minutes = 480
|
||||
added = int(override.addedMinutes if override else 0)
|
||||
effective = base_minutes + added
|
||||
max_util = float(override.maxPlannedUtilization if override and override.maxPlannedUtilization else 0.85)
|
||||
planned = self._sum_minutes(touches)
|
||||
current_mix = self._distribution(planned)
|
||||
baseline = self._suggested_baseline(role=str(ctx.user.role or ""), team_mix=team_mix, planned=planned, override=override)
|
||||
utilization = planned.total / max(effective, 1)
|
||||
capacity_status = self._capacity_status(utilization=utilization, override=override, tools=ctx.tools)
|
||||
remaining = round(effective * max_util - planned.total)
|
||||
bottleneck = self._bottleneck(planned)
|
||||
explanations = self._explanations(ctx=ctx, planned=planned, baseline=baseline, override=override, touches=touches)
|
||||
return PMFlowMemberCapacityPlan(
|
||||
userId=str(ctx.user.id),
|
||||
displayName=str(ctx.user.name or ctx.user.username),
|
||||
role=str(ctx.user.role or "MEMBER"),
|
||||
baseAvailableMinutes=base_minutes,
|
||||
calendarBlockedMinutes=0,
|
||||
effectiveMinutes=effective,
|
||||
reservedBufferMinutes=round(effective * max(0.0, 1 - max_util)),
|
||||
maxPlannedUtilization=max_util,
|
||||
suggestedBaseline=baseline,
|
||||
currentMix=current_mix,
|
||||
plannedTouchMinutes=planned,
|
||||
capacityStatus=capacity_status,
|
||||
remainingMinutes=remaining,
|
||||
bottleneckType=bottleneck,
|
||||
fatigueRisk=self._fatigue_risk(planned=planned, effective=effective),
|
||||
riskDensity={
|
||||
"high": len([touch for touch in touches if touch.riskLevel == "high"]),
|
||||
"medium": len([touch for touch in touches if touch.riskLevel == "medium"]),
|
||||
"total": len(touches),
|
||||
},
|
||||
canTakeMore=self._capacity_fit(project_id=project_id, planned=planned, effective=effective, max_util=max_util),
|
||||
manualOverride=override,
|
||||
explanations=explanations,
|
||||
)
|
||||
|
||||
def _suggested_baseline(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
team_mix: PMFlowWorkTypeDistribution,
|
||||
planned: PMFlowWorkTypeMinutes,
|
||||
override: Optional[PMFlowCapacityOverrideRecord],
|
||||
) -> PMFlowWorkTypeDistribution:
|
||||
assigned_mix = self._distribution(planned) if planned.total else None
|
||||
base = {key: getattr(team_mix, key) for key in WORK_TYPES}
|
||||
if assigned_mix and planned.total:
|
||||
base = {key: (getattr(assigned_mix, key) * 0.7) + (base[key] * 0.3) for key in WORK_TYPES}
|
||||
elif not any(base.values()):
|
||||
role_token = role.upper()
|
||||
if "OWNER" in role_token:
|
||||
base = {"focus": 0.5, "review": 0.15, "acceptance": 0.2, "coordination": 0.15}
|
||||
elif "REVIEWER" in role_token:
|
||||
base = {"focus": 0.2, "review": 0.5, "acceptance": 0.2, "coordination": 0.1}
|
||||
elif "OPERATOR" in role_token:
|
||||
base = {"focus": 0.15, "review": 0.1, "acceptance": 0.25, "coordination": 0.5}
|
||||
else:
|
||||
base = {"focus": 0.25, "review": 0.2, "acceptance": 0.35, "coordination": 0.2}
|
||||
if override and override.workTypeBias:
|
||||
for key in WORK_TYPES:
|
||||
if key in override.workTypeBias:
|
||||
base[key] = max(0.0, float(override.workTypeBias[key]))
|
||||
return self._normalize_distribution(base)
|
||||
|
||||
def _capacity_fit(self, *, project_id: int, planned: PMFlowWorkTypeMinutes, effective: int, max_util: float) -> list[PMFlowCapacityFit]:
|
||||
candidates: list[tuple[PMFlowHumanWorkType, str, int]] = [
|
||||
("focus", "低/中风险判断", 45),
|
||||
("review", "普通审查", 45),
|
||||
("acceptance", "AI 结果验收", 30),
|
||||
("coordination", "补上下文/工具阻塞", 25),
|
||||
]
|
||||
result: list[PMFlowCapacityFit] = []
|
||||
for work_type, label, minutes in candidates:
|
||||
next_total = planned.total + minutes
|
||||
next_type = getattr(planned, work_type) + minutes
|
||||
total_util = next_total / max(effective, 1)
|
||||
type_share = next_type / max(effective, 1)
|
||||
status = "recommended"
|
||||
reason = "加入后仍处于可计划容量内。"
|
||||
if total_util > max_util * 1.1:
|
||||
status, reason = "not_recommended", "加入后超过今日建议计划容量。"
|
||||
elif total_util > max_util or type_share > self._soft_share(work_type):
|
||||
status, reason = "allowed_with_warning", "加入后接近容量或该类型疲劳风险上升。"
|
||||
result.append(
|
||||
PMFlowCapacityFit(
|
||||
workType=work_type,
|
||||
label=label,
|
||||
status=status,
|
||||
estimatedMinutes=minutes,
|
||||
afterTotalUtilization=round(total_util, 3),
|
||||
afterTypeShare=round(type_share, 3),
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def _recommendations(self, *, touches: list[PMFlowHumanTouchEstimate], plans: list[PMFlowMemberCapacityPlan]) -> list[str]:
|
||||
reduction = sum(max(0, touch.aiReductionPotentialMinutes) for touch in touches)
|
||||
recommendations: list[str] = []
|
||||
if reduction:
|
||||
recommendations.append(f"建议先让 AI 预处理可转移触点,预计释放 {reduction} 分钟人工判断/审查容量。")
|
||||
overloaded = [plan.displayName for plan in plans if plan.capacityStatus in {"near_full", "overloaded"}]
|
||||
if overloaded:
|
||||
recommendations.append(f"{'、'.join(overloaded[:3])} 接近或超过今日建议容量,新增任务前需要模拟。")
|
||||
if not recommendations:
|
||||
recommendations.append("当前容量计划未发现明显瓶颈,可继续按执行能力图谱推进。")
|
||||
return recommendations
|
||||
|
||||
def _sum_minutes(self, touches: list[PMFlowHumanTouchEstimate]) -> PMFlowWorkTypeMinutes:
|
||||
totals = {key: 0 for key in WORK_TYPES}
|
||||
for touch in touches:
|
||||
totals[touch.workType] += int(touch.estimatedMinutes or 0)
|
||||
return PMFlowWorkTypeMinutes(**totals, total=sum(totals.values()))
|
||||
|
||||
def _distribution(self, minutes: PMFlowWorkTypeMinutes) -> PMFlowWorkTypeDistribution:
|
||||
total = max(minutes.total, 1)
|
||||
return PMFlowWorkTypeDistribution(**{key: round(getattr(minutes, key) / total, 3) for key in WORK_TYPES})
|
||||
|
||||
def _normalize_distribution(self, values: dict[str, float]) -> PMFlowWorkTypeDistribution:
|
||||
total = sum(max(0.0, float(values.get(key, 0.0))) for key in WORK_TYPES) or 1.0
|
||||
return PMFlowWorkTypeDistribution(**{key: round(max(0.0, float(values.get(key, 0.0))) / total, 3) for key in WORK_TYPES})
|
||||
|
||||
def _capacity_status(self, *, utilization: float, override: Optional[PMFlowCapacityOverrideRecord], tools: list[str]) -> str:
|
||||
if override and override.overrideType == "lock_capacity":
|
||||
return "blocked"
|
||||
if utilization > 1.1:
|
||||
return "overloaded"
|
||||
if override and override.addedMinutes > 0:
|
||||
return "manually_extended"
|
||||
if utilization >= 0.9:
|
||||
return "near_full"
|
||||
if utilization >= 0.7:
|
||||
return "attention"
|
||||
return "normal"
|
||||
|
||||
def _fatigue_risk(self, *, planned: PMFlowWorkTypeMinutes, effective: int) -> dict[str, str]:
|
||||
risks: dict[str, str] = {}
|
||||
for key in WORK_TYPES:
|
||||
share = getattr(planned, key) / max(effective, 1)
|
||||
if share > self._hard_share(key):
|
||||
risks[key] = "high"
|
||||
elif share > self._soft_share(key):
|
||||
risks[key] = "medium"
|
||||
else:
|
||||
risks[key] = "low"
|
||||
return risks
|
||||
|
||||
def _soft_share(self, work_type: str) -> float:
|
||||
return {"focus": 0.7, "review": 0.65, "acceptance": 0.75, "coordination": 0.6}.get(work_type, 0.7)
|
||||
|
||||
def _hard_share(self, work_type: str) -> float:
|
||||
return {"focus": 0.85, "review": 0.8, "acceptance": 0.9, "coordination": 0.75}.get(work_type, 0.85)
|
||||
|
||||
def _bottleneck(self, minutes: PMFlowWorkTypeMinutes) -> Optional[str]:
|
||||
values = {key: getattr(minutes, key) for key in WORK_TYPES}
|
||||
if not any(values.values()):
|
||||
return None
|
||||
return max(values.items(), key=lambda item: item[1])[0]
|
||||
|
||||
def _explanations(
|
||||
self,
|
||||
*,
|
||||
ctx: _MemberContext,
|
||||
planned: PMFlowWorkTypeMinutes,
|
||||
baseline: PMFlowWorkTypeDistribution,
|
||||
override: Optional[PMFlowCapacityOverrideRecord],
|
||||
touches: list[PMFlowHumanTouchEstimate],
|
||||
) -> list[str]:
|
||||
explanations = [
|
||||
f"今日动态基线来自当前任务池:深度判断 {round(baseline.focus * 100)}%,审查 {round(baseline.review * 100)}%,验收 {round(baseline.acceptance * 100)}%,协调 {round(baseline.coordination * 100)}%。",
|
||||
f"当前已计划人工触点 {planned.total} 分钟,包含 {len(touches)} 个真实触点。",
|
||||
]
|
||||
if override:
|
||||
explanations.append(f"负责人已手工调整:{override.reason}")
|
||||
if not ctx.tools:
|
||||
explanations.append("未检测到活跃 Cursor / VS Code / Codex 工具连接,工具准备度会影响可承接建议。")
|
||||
return explanations[:4]
|
||||
|
||||
def _payload_to_record(self, payload: PMFlowCapacityOverridePayload, *, plan_date: date, created_by: int) -> PMFlowCapacityOverrideRecord:
|
||||
return PMFlowCapacityOverrideRecord(
|
||||
id="simulated",
|
||||
userId=str(payload.userId),
|
||||
date=plan_date.isoformat(),
|
||||
overrideType=payload.overrideType,
|
||||
addedMinutes=int(payload.addedMinutes or 0),
|
||||
overtimeType=payload.overtimeType,
|
||||
maxPlannedUtilization=payload.maxPlannedUtilization,
|
||||
workTypeBias=dict(payload.workTypeBias or {}),
|
||||
highRiskLimit=payload.highRiskLimit,
|
||||
reason=payload.reason,
|
||||
expiresAt=self._parse_expires(payload.expiresAt, plan_date=plan_date).isoformat(),
|
||||
createdBy=str(created_by),
|
||||
createdAt=None,
|
||||
)
|
||||
|
||||
def _override_record(self, row: HumanCapacityOverride) -> PMFlowCapacityOverrideRecord:
|
||||
return PMFlowCapacityOverrideRecord(
|
||||
id=str(row.id),
|
||||
userId=str(row.user_id),
|
||||
date=row.date.isoformat(),
|
||||
overrideType=row.override_type,
|
||||
addedMinutes=int(row.added_minutes or 0),
|
||||
overtimeType=row.overtime_type,
|
||||
maxPlannedUtilization=row.max_planned_utilization,
|
||||
workTypeBias=dict(row.work_type_bias or {}),
|
||||
highRiskLimit=row.high_risk_limit,
|
||||
reason=row.reason,
|
||||
expiresAt=row.expires_at.isoformat(),
|
||||
createdBy=str(row.created_by),
|
||||
createdAt=row.created_at.isoformat() if row.created_at else None,
|
||||
)
|
||||
|
||||
def _find_member_plan(self, plan: PMFlowDailyCapacityPlan, user_id: str) -> PMFlowMemberCapacityPlan:
|
||||
for item in plan.memberPlans:
|
||||
if str(item.userId) == str(user_id):
|
||||
return item
|
||||
raise ValueError("capacity_member_not_found")
|
||||
|
||||
def _parse_date(self, value: Optional[str]) -> date:
|
||||
if not value:
|
||||
return now_cn_naive().date()
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
def _parse_expires(self, value: Optional[str], *, plan_date: date) -> datetime:
|
||||
if value:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return parsed.replace(tzinfo=None)
|
||||
return datetime.combine(plan_date, time(23, 59, 59))
|
||||
|
||||
def _risk(self, value: Any) -> str:
|
||||
token = str(value or "low").lower()
|
||||
return token if token in {"low", "medium", "high"} else "low"
|
||||
|
||||
def _get(self, obj: Any, key: str) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key)
|
||||
return getattr(obj, key, None)
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
"""AI routing and provider governance aggregation service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import AICallLog, Integration
|
||||
from ..services.model.model_config import (
|
||||
build_routing_config_snapshot,
|
||||
find_matching_rule,
|
||||
get_provider_defaults,
|
||||
get_tier_defaults,
|
||||
load_model_config,
|
||||
)
|
||||
from ..utils.timezone import now_cn_naive
|
||||
|
||||
TEAM_POLICY_INTEGRATION_TYPE = "ai_team_policy"
|
||||
LLM_ENGINE_INTEGRATION_TYPE = "llm_engine"
|
||||
MODEL_ROUTING_INTEGRATION_TYPE = "model_routing"
|
||||
|
||||
PROVIDER_LABELS = {
|
||||
"glm": "Qwen / DashScope",
|
||||
"openai": "OpenAI 兼容网关",
|
||||
"deepseek": "DeepSeek",
|
||||
"ollama": "本地 Ollama",
|
||||
}
|
||||
|
||||
SLOT_LABELS = {
|
||||
"low": "快速槽",
|
||||
"high": "深度槽",
|
||||
}
|
||||
|
||||
PROTECTED_FUNCTIONS = {
|
||||
"adaptive_rag_answer_deep",
|
||||
"pmflow_launch_draft_v1",
|
||||
"pmflow_requirement_plan_draft",
|
||||
"pmflow_requirement_plan_round",
|
||||
"project_requirement_extraction",
|
||||
"generate_task_breakdown",
|
||||
}
|
||||
|
||||
SCENE_FUNCTIONS = {
|
||||
"project_launch": ["pmflow_launch_draft_v1", "project_from_text_analysis", "project_from_text_charter"],
|
||||
"requirement_structuring": ["project_requirement_extraction", "pmflow_requirement_plan_draft", "pmflow_requirement_plan_round"],
|
||||
"task_breakdown": ["generate_task_breakdown", "decompose_tasks_from_charter", "sstc_ai_breakdown"],
|
||||
"assignee_matching": ["recommend_assignees_for_task"],
|
||||
"internal_qa": ["adaptive_rag_answer_normal", "adaptive_rag_answer_deep", "rag_db_project_semantic_retrieval"],
|
||||
"external_query": ["external_query_plan", "adaptive_rag_answer"],
|
||||
"feishu_understanding": ["feishu_level_classification", "feishu_signal_extraction", "pmflow_semantic_context_decision"],
|
||||
"feishu_reply": ["feishu_humanized_reply", "feishu_qa_direct_natural_reply"],
|
||||
}
|
||||
|
||||
SCENE_LABELS = {
|
||||
"project_launch": "项目启动草案",
|
||||
"requirement_structuring": "需求结构化",
|
||||
"task_breakdown": "任务拆解",
|
||||
"assignee_matching": "人员匹配",
|
||||
"internal_qa": "内部问答",
|
||||
"external_query": "外部实时查询",
|
||||
"feishu_understanding": "飞书理解",
|
||||
"feishu_reply": "飞书回复",
|
||||
}
|
||||
|
||||
DEFAULT_SCENE_POLICIES = {
|
||||
"project_launch": "快速起草,失败后深度修复,结果必须进入确认",
|
||||
"requirement_structuring": "深度优先,保护项目真相层入口",
|
||||
"task_breakdown": "快速结构化,失败后深度修复,仍失败则标记待补充",
|
||||
"assignee_matching": "深度分析,必须结合人员能力图谱",
|
||||
"internal_qa": "先走受控内部查询,再按复杂度组织答案",
|
||||
"external_query": "外部查询必须经过计划、来源校验和答案验证",
|
||||
"feishu_understanding": "快速理解与分流,低置信度进入澄清",
|
||||
"feishu_reply": "快速润色,不改变业务结论",
|
||||
}
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return json.loads(json.dumps(value))
|
||||
except Exception:
|
||||
return dict(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _mask_secret(secret: str) -> str:
|
||||
value = str(secret or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= 8:
|
||||
return "*" * len(value)
|
||||
return f"{value[:4]}...{value[-4:]}"
|
||||
|
||||
|
||||
def _looks_placeholder(value: str) -> bool:
|
||||
text = str(value or "").strip().lower()
|
||||
return bool(text) and any(marker in text for marker in ("placeholder", "dummy", "test-key", "local-placeholder"))
|
||||
|
||||
|
||||
def _normalize_tier(value: Any) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return text if text in {"low", "high"} else ""
|
||||
|
||||
|
||||
def _iso(value: Any) -> str | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
class AIRoutingGovernanceService:
|
||||
def __init__(self, db: AsyncSession, *, tenant_id: int):
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
|
||||
async def get_overview(self) -> dict[str, Any]:
|
||||
bundle = load_model_config()
|
||||
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
|
||||
routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE)
|
||||
slots = self._build_slots(engine_bundle=engine_bundle, config_bundle=bundle)
|
||||
metrics = await self._build_metrics()
|
||||
diagnostics = await self._build_diagnostics(
|
||||
engine_bundle=engine_bundle,
|
||||
routing_bundle=routing_bundle,
|
||||
slots=slots,
|
||||
metrics=metrics,
|
||||
)
|
||||
snapshot = build_routing_config_snapshot(bundle=bundle)
|
||||
return {
|
||||
"config_version": snapshot.get("routing_config_version") or "",
|
||||
"config_updated_at": snapshot.get("routing_config_updated_at") or "",
|
||||
"source_of_truth": "PMFlow 本地路由层 baseline",
|
||||
"slots": slots,
|
||||
"metrics": metrics,
|
||||
"diagnostics": diagnostics,
|
||||
"latency_status": "暂未采集平均耗时",
|
||||
}
|
||||
|
||||
async def get_admin_console(self) -> dict[str, Any]:
|
||||
overview = await self.get_overview()
|
||||
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
|
||||
routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE)
|
||||
config_bundle = load_model_config()
|
||||
return {
|
||||
"overview": overview,
|
||||
"providers": self._build_providers(engine_bundle=engine_bundle),
|
||||
"scene_routes": self._build_scene_routes(routing_bundle=routing_bundle, config_bundle=config_bundle),
|
||||
"protected_functions": sorted(PROTECTED_FUNCTIONS),
|
||||
"recent_failures": await self._recent_failures(limit=8),
|
||||
"diagnostics": overview["diagnostics"],
|
||||
"audit_hint": "策略变更会写入操作日志,底层模型默认值仍由 PMFlow 本地路由层 baseline 管理。",
|
||||
}
|
||||
|
||||
async def run_diagnostics(self) -> dict[str, Any]:
|
||||
overview = await self.get_overview()
|
||||
return {
|
||||
"checked_at": now_cn_naive().isoformat(),
|
||||
"items": overview["diagnostics"],
|
||||
"summary": self._diagnostic_summary(overview["diagnostics"]),
|
||||
}
|
||||
|
||||
async def get_team_policy(self) -> dict[str, Any]:
|
||||
saved = await self._load_integration_config(TEAM_POLICY_INTEGRATION_TYPE)
|
||||
return self._merge_team_policy(saved)
|
||||
|
||||
async def update_team_policy(self, *, payload: dict[str, Any], updated_by: int) -> dict[str, Any]:
|
||||
normalized = self._merge_team_policy(payload)
|
||||
normalized["updated_by"] = int(updated_by)
|
||||
normalized["updated_at"] = now_cn_naive().isoformat()
|
||||
row = await self._get_or_init_integration(TEAM_POLICY_INTEGRATION_TYPE, default_config={})
|
||||
row.config = json.loads(json.dumps(normalized, ensure_ascii=False))
|
||||
row.is_active = True
|
||||
await self.db.flush()
|
||||
return normalized
|
||||
|
||||
async def _load_integration_config(self, integration_type: str) -> dict[str, Any]:
|
||||
row = (
|
||||
await self.db.execute(
|
||||
select(Integration).where(
|
||||
and_(
|
||||
Integration.tenant_id == self.tenant_id,
|
||||
Integration.type == integration_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return _safe_dict(row.config if row else {})
|
||||
|
||||
async def _get_or_init_integration(self, integration_type: str, *, default_config: dict[str, Any]) -> Integration:
|
||||
row = (
|
||||
await self.db.execute(
|
||||
select(Integration).where(
|
||||
and_(
|
||||
Integration.tenant_id == self.tenant_id,
|
||||
Integration.type == integration_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = Integration(
|
||||
tenant_id=self.tenant_id,
|
||||
type=integration_type,
|
||||
config=json.loads(json.dumps(default_config, ensure_ascii=False)),
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(row)
|
||||
await self.db.flush()
|
||||
return row
|
||||
|
||||
def _merge_team_policy(self, saved: dict[str, Any]) -> dict[str, Any]:
|
||||
strategy = str(saved.get("strategy") or "balanced").strip()
|
||||
legacy_strategy_map = {"quality": "quality_first", "speed": "speed_first"}
|
||||
strategy = legacy_strategy_map.get(strategy, strategy)
|
||||
if strategy not in {"quality_first", "balanced", "speed_first"}:
|
||||
strategy = "balanced"
|
||||
saved_scenes = {
|
||||
str(item.get("key") or ""): item
|
||||
for item in saved.get("scenes", [])
|
||||
if isinstance(item, dict) and str(item.get("key") or "") in SCENE_LABELS
|
||||
}
|
||||
scenes: list[dict[str, Any]] = []
|
||||
for key, label in SCENE_LABELS.items():
|
||||
item = saved_scenes.get(key, {})
|
||||
scenes.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"policy": str(item.get("policy") or DEFAULT_SCENE_POLICIES[key]).strip(),
|
||||
"mode": str(item.get("mode") or self._default_scene_mode(key)).strip(),
|
||||
"human_confirmation": bool(item.get("human_confirmation", key in {"project_launch", "requirement_structuring", "task_breakdown"})),
|
||||
"protected": bool(item.get("protected", key in {"project_launch", "requirement_structuring", "task_breakdown", "assignee_matching"})),
|
||||
}
|
||||
)
|
||||
budget_guard = saved.get("budget_guard") if isinstance(saved.get("budget_guard"), dict) else {}
|
||||
return {
|
||||
"strategy": strategy,
|
||||
"strategy_label": {"quality_first": "质量优先", "balanced": "成本均衡", "speed_first": "速度优先"}[strategy],
|
||||
"scenes": scenes,
|
||||
"automation_boundary": str(
|
||||
saved.get("automation_boundary")
|
||||
or "高风险写操作只生成待确认草案,禁止模型直接改生产状态。"
|
||||
).strip(),
|
||||
"budget_guard": {
|
||||
"enabled": bool(budget_guard.get("enabled", False)),
|
||||
"monthly_budget_cny": budget_guard.get("monthly_budget_cny"),
|
||||
"alert_threshold_ratio": float(budget_guard.get("alert_threshold_ratio", 0.8) or 0.8),
|
||||
},
|
||||
"quality_targets": self._normalize_quality_targets(saved.get("quality_targets")),
|
||||
"updated_at": str(saved.get("updated_at") or "") or None,
|
||||
"updated_by": saved.get("updated_by"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_quality_targets(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
items = [str(item).strip() for item in value if str(item or "").strip()]
|
||||
return items[:8] if items else AIRoutingGovernanceService._default_quality_targets()
|
||||
return AIRoutingGovernanceService._default_quality_targets()
|
||||
|
||||
@staticmethod
|
||||
def _default_quality_targets() -> list[str]:
|
||||
return [
|
||||
"项目启动、需求结构化、任务拆解失败时必须显式进入待确认或失败态",
|
||||
"内部问答必须保留来源合同,外部查询必须经过实时来源校验",
|
||||
"保护场景不得被租户覆盖降级到快速槽",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _default_scene_mode(key: str) -> str:
|
||||
if key in {"requirement_structuring", "assignee_matching"}:
|
||||
return "深度优先"
|
||||
if key == "external_query":
|
||||
return "外部校验"
|
||||
if key in {"feishu_understanding", "feishu_reply"}:
|
||||
return "快速处理"
|
||||
return "快速起草,失败升级"
|
||||
|
||||
def _build_slots(self, *, engine_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
|
||||
tier_defaults = config_bundle.get("tier_defaults") if isinstance(config_bundle.get("tier_defaults"), dict) else get_tier_defaults()
|
||||
slots: list[dict[str, Any]] = []
|
||||
for tier in ("low", "high"):
|
||||
defaults = tier_defaults.get(tier) if isinstance(tier_defaults.get(tier), dict) else {}
|
||||
provider = str(defaults.get("provider") or "").strip().lower()
|
||||
model = str(defaults.get("model") or "").strip()
|
||||
base_url = str(defaults.get("base_url") or "").strip()
|
||||
matching_engine = next(
|
||||
(item for item in engines if str(item.get("provider") or "").strip().lower() == provider),
|
||||
None,
|
||||
)
|
||||
env_configured = bool(os.getenv(str(defaults.get("api_env") or "").strip(), "").strip()) if defaults.get("api_env") else False
|
||||
source = "租户配置" if matching_engine else "环境变量" if env_configured else "未配置"
|
||||
api_key_masked = _mask_secret(str((matching_engine or {}).get("api_key") or "")) if matching_engine else ""
|
||||
slots.append(
|
||||
{
|
||||
"tier": tier,
|
||||
"label": SLOT_LABELS[tier],
|
||||
"provider": provider,
|
||||
"provider_label": PROVIDER_LABELS.get(provider, provider or "未配置"),
|
||||
"model": model,
|
||||
"base_url": base_url,
|
||||
"configured": bool(matching_engine or env_configured or provider == "ollama"),
|
||||
"config_source": source,
|
||||
"api_key_masked": api_key_masked,
|
||||
}
|
||||
)
|
||||
return slots
|
||||
|
||||
def _build_providers(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
active_id = str(engine_bundle.get("active_engine_id") or "")
|
||||
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
|
||||
providers = []
|
||||
for item in engines:
|
||||
provider = str(item.get("provider") or "").strip().lower()
|
||||
providers.append(
|
||||
{
|
||||
"id": str(item.get("id") or ""),
|
||||
"name": str(item.get("name") or PROVIDER_LABELS.get(provider, provider)).strip(),
|
||||
"provider": provider,
|
||||
"provider_label": PROVIDER_LABELS.get(provider, provider),
|
||||
"model": str(item.get("model") or ""),
|
||||
"base_url": str(item.get("base_url") or ""),
|
||||
"api_key_masked": _mask_secret(str(item.get("api_key") or "")),
|
||||
"active": str(item.get("id") or "") == active_id,
|
||||
"status_label": "已启用" if str(item.get("id") or "") == active_id else "已配置",
|
||||
"updated_at": _iso(item.get("updated_at")),
|
||||
}
|
||||
)
|
||||
return providers
|
||||
|
||||
def _build_scene_routes(self, *, routing_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {}
|
||||
routes: list[dict[str, Any]] = []
|
||||
for scene_key, functions in SCENE_FUNCTIONS.items():
|
||||
function_rows: list[dict[str, Any]] = []
|
||||
tiers: list[str] = []
|
||||
for function_name in functions:
|
||||
override = mappings.get(function_name) if isinstance(mappings.get(function_name), dict) else None
|
||||
override_tier = _normalize_tier((override or {}).get("tier_alias") or (override or {}).get("tier"))
|
||||
rule = find_matching_rule(function_name, tier_filter={override_tier} if override_tier else None, bundle=config_bundle)
|
||||
tier = override_tier or _normalize_tier((rule or {}).get("tier")) or "low"
|
||||
tiers.append(tier)
|
||||
function_rows.append(
|
||||
{
|
||||
"function_name": function_name,
|
||||
"display_name": self._function_display_name(function_name),
|
||||
"tier": tier,
|
||||
"slot_label": SLOT_LABELS.get(tier, tier),
|
||||
"source": "override" if override_tier else "default",
|
||||
"rule_name": str((rule or {}).get("name") or "") or None,
|
||||
}
|
||||
)
|
||||
primary_tier = "mixed" if len(set(tiers)) > 1 else (tiers[0] if tiers else "low")
|
||||
routes.append(
|
||||
{
|
||||
"scene_key": scene_key,
|
||||
"label": SCENE_LABELS[scene_key],
|
||||
"summary": DEFAULT_SCENE_POLICIES[scene_key],
|
||||
"primary_slot_label": "混合策略" if primary_tier == "mixed" else SLOT_LABELS.get(primary_tier, "快速槽"),
|
||||
"functions": function_rows,
|
||||
}
|
||||
)
|
||||
return routes
|
||||
|
||||
async def _build_metrics(self) -> dict[str, Any]:
|
||||
return {
|
||||
"last_24h": await self._window_metrics(hours=24),
|
||||
"last_7d": await self._window_metrics(hours=24 * 7),
|
||||
}
|
||||
|
||||
async def _window_metrics(self, *, hours: int) -> dict[str, Any]:
|
||||
since = now_cn_naive() - timedelta(hours=hours)
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(AICallLog).where(
|
||||
and_(
|
||||
AICallLog.tenant_id == self.tenant_id,
|
||||
AICallLog.created_at >= since,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
total = len(rows)
|
||||
failed = sum(1 for row in rows if self._is_failed_status(str(row.status or "")))
|
||||
upgraded = sum(
|
||||
1
|
||||
for row in rows
|
||||
if str(row.planned_tier or "").strip()
|
||||
and str(row.executed_tier or "").strip()
|
||||
and str(row.planned_tier or "").strip() != str(row.executed_tier or "").strip()
|
||||
)
|
||||
structured_failed = sum(1 for row in rows if self._is_structured_failure(str(row.status or "")))
|
||||
total_cost = sum(float(row.cost or Decimal("0")) for row in rows)
|
||||
return {
|
||||
"total_calls": total,
|
||||
"failed_calls": failed,
|
||||
"failure_rate": round(failed / total, 4) if total else 0.0,
|
||||
"upgraded_calls": upgraded,
|
||||
"upgrade_rate": round(upgraded / total, 4) if total else 0.0,
|
||||
"structured_failed_calls": structured_failed,
|
||||
"estimated_cost": round(total_cost, 6),
|
||||
"average_latency_ms": None,
|
||||
}
|
||||
|
||||
async def _build_diagnostics(
|
||||
self,
|
||||
*,
|
||||
engine_bundle: dict[str, Any],
|
||||
routing_bundle: dict[str, Any],
|
||||
slots: list[dict[str, Any]],
|
||||
metrics: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
items.extend(self._env_override_diagnostics(engine_bundle=engine_bundle))
|
||||
if any(not slot.get("configured") for slot in slots if slot.get("tier") == "high"):
|
||||
items.append(self._risk("high_unavailable", "critical", "深度槽未配置可用供应商", "项目启动、需求结构化和复杂问答可能显式失败。"))
|
||||
if len(slots) == 2 and self._same_slot_runtime(slots[0], slots[1]):
|
||||
items.append(self._risk("same_slot_runtime", "warning", "快速槽和深度槽指向同一运行时", "低阶失败后升级无法获得更强模型能力。"))
|
||||
items.extend(self._protected_downgrade_diagnostics(routing_bundle=routing_bundle))
|
||||
last_24h = metrics.get("last_24h") if isinstance(metrics.get("last_24h"), dict) else {}
|
||||
if int(last_24h.get("total_calls") or 0) >= 3 and float(last_24h.get("failure_rate") or 0) >= 0.3:
|
||||
items.append(self._risk("high_failure_rate", "warning", "最近 24 小时 AI 调用失败率偏高", "建议先检查供应商 Key、额度、结构化输出和高阶槽位。"))
|
||||
if int(last_24h.get("structured_failed_calls") or 0) >= 3:
|
||||
items.append(self._risk("structured_failure_rate", "warning", "结构化输出失败偏多", "项目启动、任务拆解和语义事实抽取可能进入待补充或失败状态。"))
|
||||
if self._ollama_runtime_risk(engine_bundle=engine_bundle, slots=slots):
|
||||
items.append(self._risk("ollama_scope_risk", "warning", "本地 Ollama 只能用于知识库问答", "若被绑定到项目创建或任务拆解,会被运行边界拦截。"))
|
||||
if not items:
|
||||
items.append({"code": "ok", "severity": "ok", "title": "未发现明显配置风险", "message": "当前供应商、槽位和路由配置未触发内置风险规则。"})
|
||||
return items
|
||||
|
||||
def _env_override_diagnostics(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
defaults = get_provider_defaults()
|
||||
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
|
||||
for engine in engines:
|
||||
provider = str(engine.get("provider") or "").strip().lower()
|
||||
provider_defaults = defaults.get(provider) if isinstance(defaults.get(provider), dict) else {}
|
||||
api_env = str(provider_defaults.get("api_env") or "").strip()
|
||||
if not api_env:
|
||||
continue
|
||||
env_value = str(os.getenv(api_env, "") or "").strip()
|
||||
db_value = str(engine.get("api_key") or "").strip()
|
||||
if not env_value or not db_value or env_value == db_value:
|
||||
continue
|
||||
severity = "critical" if _looks_placeholder(env_value) else "warning"
|
||||
items.append(
|
||||
self._risk(
|
||||
"env_key_override",
|
||||
severity,
|
||||
f"{PROVIDER_LABELS.get(provider, provider)} 环境变量正在覆盖租户密钥",
|
||||
"运行时会优先使用环境变量中的密钥;若该值是占位符,会导致供应商请求失败。",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def _protected_downgrade_diagnostics(self, *, routing_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {}
|
||||
downgraded = []
|
||||
for function_name in PROTECTED_FUNCTIONS:
|
||||
override = mappings.get(function_name) if isinstance(mappings.get(function_name), dict) else None
|
||||
tier = _normalize_tier((override or {}).get("tier_alias") or (override or {}).get("tier"))
|
||||
if tier == "low":
|
||||
downgraded.append(function_name)
|
||||
if not downgraded:
|
||||
return []
|
||||
return [
|
||||
self._risk(
|
||||
"protected_function_downgraded",
|
||||
"critical",
|
||||
"保护场景被覆盖到快速槽",
|
||||
f"涉及 {len(downgraded)} 个保护函数;这些场景应保持深度或 low-first 高阶修复。",
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _risk(code: str, severity: str, title: str, message: str) -> dict[str, Any]:
|
||||
return {"code": code, "severity": severity, "title": title, "message": message}
|
||||
|
||||
@staticmethod
|
||||
def _same_slot_runtime(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower()
|
||||
and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower()
|
||||
and str(a.get("base_url") or "").strip().rstrip("/").lower()
|
||||
== str(b.get("base_url") or "").strip().rstrip("/").lower()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ollama_runtime_risk(*, engine_bundle: dict[str, Any], slots: list[dict[str, Any]]) -> bool:
|
||||
settings = engine_bundle.get("settings") if isinstance(engine_bundle.get("settings"), dict) else {}
|
||||
if bool(settings.get("enable_local_kb_qa")):
|
||||
return False
|
||||
return any(str(slot.get("provider") or "").strip().lower() == "ollama" for slot in slots)
|
||||
|
||||
@staticmethod
|
||||
def _is_failed_status(status: str) -> bool:
|
||||
normalized = str(status or "").strip().lower()
|
||||
if not normalized:
|
||||
return True
|
||||
return not (
|
||||
normalized.startswith("success")
|
||||
or normalized in {"ok", "success_cache_hit", "success_failover"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_structured_failure(status: str) -> bool:
|
||||
normalized = str(status or "").strip().lower()
|
||||
return "structured" in normalized and any(marker in normalized for marker in ("error", "empty", "invalid", "failed"))
|
||||
|
||||
async def _recent_failures(self, *, limit: int) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(AICallLog)
|
||||
.where(AICallLog.tenant_id == self.tenant_id)
|
||||
.order_by(AICallLog.created_at.desc(), AICallLog.id.desc())
|
||||
.limit(80)
|
||||
)
|
||||
).scalars().all()
|
||||
failures: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not self._is_failed_status(str(row.status or "")):
|
||||
continue
|
||||
failures.append(
|
||||
{
|
||||
"function_label": self._function_display_name(str(row.function_name or "")),
|
||||
"function_name": str(row.function_name or ""),
|
||||
"status_label": self._status_label(str(row.status or "")),
|
||||
"planned_slot": SLOT_LABELS.get(str(row.planned_tier or "").strip().lower(), str(row.planned_tier or "") or "-"),
|
||||
"executed_slot": SLOT_LABELS.get(str(row.executed_tier or "").strip().lower(), str(row.executed_tier or "") or "-"),
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
)
|
||||
if len(failures) >= limit:
|
||||
break
|
||||
return failures
|
||||
|
||||
@staticmethod
|
||||
def _function_display_name(function_name: str) -> str:
|
||||
labels = {
|
||||
"pmflow_launch_draft_v1": "项目启动草案",
|
||||
"pmflow_semantic_context_decision": "语义上下文决策",
|
||||
"adaptive_rag_answer_deep": "深度问答生成",
|
||||
"adaptive_rag_answer_normal": "常规问答生成",
|
||||
"generate_task_breakdown": "任务拆解",
|
||||
"rag_db_project_semantic_retrieval": "项目语义检索",
|
||||
"feishu_humanized_reply": "飞书回复润色",
|
||||
"signal_planner_analyze": "信号规划分析",
|
||||
}
|
||||
return labels.get(str(function_name or "").strip(), str(function_name or "").strip() or "未知调用")
|
||||
|
||||
@staticmethod
|
||||
def _status_label(status: str) -> str:
|
||||
normalized = str(status or "").strip().lower()
|
||||
if "http" in normalized:
|
||||
return "供应商请求失败"
|
||||
if "empty" in normalized:
|
||||
return "模型返回为空"
|
||||
if "invalid" in normalized:
|
||||
return "结构化结果不合法"
|
||||
if "cache" in normalized:
|
||||
return "缓存命中"
|
||||
if normalized.startswith("success"):
|
||||
return "成功"
|
||||
return "调用失败"
|
||||
|
||||
@staticmethod
|
||||
def _diagnostic_summary(items: list[dict[str, Any]]) -> dict[str, int]:
|
||||
return {
|
||||
"critical": sum(1 for item in items if item.get("severity") == "critical"),
|
||||
"warning": sum(1 for item in items if item.get("severity") == "warning"),
|
||||
"ok": sum(1 for item in items if item.get("severity") == "ok"),
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@
|
|||
from .service import (
|
||||
AuthService,
|
||||
MANAGER_ROLES,
|
||||
can_approve_ai_routing_change,
|
||||
can_assign_tasks,
|
||||
can_manage_users,
|
||||
can_operate_ai_routing,
|
||||
can_view_ai_policy,
|
||||
has_any_role,
|
||||
has_permission,
|
||||
parse_role_values,
|
||||
|
|
@ -18,4 +21,7 @@ __all__ = [
|
|||
"parse_role_values",
|
||||
"can_manage_users",
|
||||
"can_assign_tasks",
|
||||
"can_view_ai_policy",
|
||||
"can_operate_ai_routing",
|
||||
"can_approve_ai_routing_change",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -233,3 +233,18 @@ def can_manage_users(user_role: str) -> bool:
|
|||
def can_assign_tasks(user_role: str) -> bool:
|
||||
"""Check if user can assign tasks to others"""
|
||||
return has_any_role(user_role, ["OWNER", "REVIEWER"])
|
||||
|
||||
|
||||
def can_view_ai_policy(user_role: str) -> bool:
|
||||
"""AI policy pages are visible to governance roles only."""
|
||||
return has_any_role(user_role, ["OWNER", "REVIEWER", "AI_CONFIG_VIEWER", "AI_OPERATOR", "AI_ADMIN"])
|
||||
|
||||
|
||||
def can_operate_ai_routing(user_role: str) -> bool:
|
||||
"""AI routing diagnostics/tests are available to reviewer-level operators."""
|
||||
return has_any_role(user_role, ["OWNER", "REVIEWER", "AI_OPERATOR", "AI_ADMIN"])
|
||||
|
||||
|
||||
def can_approve_ai_routing_change(user_role: str) -> bool:
|
||||
"""High-risk AI routing/policy changes require owner approval in v1."""
|
||||
return has_any_role(user_role, ["OWNER", "AI_ADMIN"])
|
||||
|
|
|
|||
|
|
@ -82,13 +82,13 @@ class ContextCacheProfileBasicsMixin:
|
|||
"aliases": ["projects", "项目", "项目列表", "project list"],
|
||||
},
|
||||
{
|
||||
"route": "/ai-steward",
|
||||
"name": "AI 管家",
|
||||
"summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
|
||||
"page_component": "AIStewardPage",
|
||||
"page_file": "frontend/src/pages/AIStewardPage.tsx",
|
||||
"apis": ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
|
||||
"aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"],
|
||||
"route": "/space",
|
||||
"name": "个人空间",
|
||||
"summary": "聚合项目、草案、待确认、AI 动态和问答入口。",
|
||||
"page_component": "PersonalSpacePage",
|
||||
"page_file": "frontend/src/pages/PersonalSpacePage.tsx",
|
||||
"apis": ["/projects/?summary=1", "/pmflow-os/projects/{project_id}/truth-state", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
|
||||
"aliases": ["space", "空间", "个人空间", "问答", "rag", "智能问答"],
|
||||
},
|
||||
{
|
||||
"route": "/ai-config",
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ class ContextCacheProfileRouteParserMixin:
|
|||
["/projects/", "/projects/?summary=1"],
|
||||
["projects", "项目", "项目列表", "project list"],
|
||||
),
|
||||
"/ai-steward": (
|
||||
"AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
|
||||
["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
|
||||
["ai steward", "ai管家", "问答", "rag", "智能问答"],
|
||||
"/space": (
|
||||
"聚合项目、草案、待确认、AI 动态和问答入口。",
|
||||
["/projects/?summary=1", "/pmflow-os/projects/{project_id}/truth-state", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
|
||||
["space", "空间", "个人空间", "问答", "rag", "智能问答"],
|
||||
),
|
||||
"/ai-config": (
|
||||
"配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。",
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ class SignalCaptureLegacyMixin:
|
|||
signal.processed = bool(linked_project_ids or auto_tasks)
|
||||
await self.db.commit()
|
||||
|
||||
target_url = "/ai-steward"
|
||||
target_url = "/space"
|
||||
if linked_project_ids:
|
||||
target_url = f"/projects/{linked_project_ids[0]}/board"
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,433 @@
|
|||
"""Execution capability snapshot for solo-first, team-ready PMFlow surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import DevWorkspaceBinding, SkillTag, User, UserProject, UserSkillBinding, UserWorkload
|
||||
from ..schemas.pmflow_os import (
|
||||
PMFlowExecutionCapabilityMode,
|
||||
PMFlowExecutionCapabilitySnapshot,
|
||||
PMFlowExecutionModeCapability,
|
||||
PMFlowPersonalExecutionReadiness,
|
||||
PMFlowTeamCapacitySignal,
|
||||
PMFlowTeamContextSignal,
|
||||
PMFlowTeamExecutionSignal,
|
||||
PMFlowTeamToolSignal,
|
||||
PMFlowToolReadinessItem,
|
||||
)
|
||||
from .adaptive_capacity_service import AdaptiveCapacityService
|
||||
from .pmflow_os_projection_service import PMFlowOSProjectionService
|
||||
|
||||
|
||||
class TeamExecutionCapabilityService:
|
||||
"""Builds read-only execution capability signals from existing PMFlow data."""
|
||||
|
||||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
|
||||
async def get_snapshot(self, *, project_id: int, current_user_id: int) -> PMFlowExecutionCapabilitySnapshot:
|
||||
projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id)
|
||||
truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id)
|
||||
execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||||
members = await self._load_project_members(project_id=project_id)
|
||||
member_ids = [int(user.id) for user in members]
|
||||
skill_map = await self._load_skill_map(member_ids=member_ids)
|
||||
workload_map = await self._load_workload_map(member_ids=member_ids)
|
||||
workspace_map = await self._load_workspace_map(project_id=project_id, member_ids=member_ids)
|
||||
pending_reviews_by_user = self._pending_review_counts_by_user(truth_state.reviewerRouting.items)
|
||||
active_execution_by_user = self._active_execution_counts_by_user(execution_summary.activeDevTasks)
|
||||
|
||||
warnings = list(execution_summary.warnings)
|
||||
if not members:
|
||||
warnings.append("team_members_empty: 当前项目没有可用于能力快照的成员归属。")
|
||||
if truth_state.reviewerRouting.summary.missingReviewerCount > 0:
|
||||
warnings.append("reviewer_missing: 存在待审 ChangeSet 缺少 reviewer/owner。")
|
||||
if not execution_summary.repoBindings:
|
||||
warnings.append("repo_missing: 未接入 repo,AI 执行包无法落到真实代码上下文。")
|
||||
if not execution_summary.promptPackage:
|
||||
warnings.append("prompt_package_missing: 未生成执行 Prompt Package。")
|
||||
if not execution_summary.recentProgressEvents:
|
||||
warnings.append("execution_evidence_missing: 缺少 branch/commit/PR/test 等执行证据。")
|
||||
|
||||
personal_readiness = self._personal_readiness(
|
||||
project_id=project_id,
|
||||
current_user_id=current_user_id,
|
||||
truth_state=truth_state,
|
||||
execution_summary=execution_summary,
|
||||
workspace_map=workspace_map,
|
||||
)
|
||||
adaptive_capacity = await AdaptiveCapacityService(self.db, tenant_id=self.tenant_id).get_plan(
|
||||
project_id=project_id,
|
||||
current_user_id=current_user_id,
|
||||
)
|
||||
team_signals = [
|
||||
self._member_signal(
|
||||
user=user,
|
||||
project_id=project_id,
|
||||
skills=skill_map.get(int(user.id), []),
|
||||
workload_ratio=workload_map.get(int(user.id), 0.0),
|
||||
workspace_tools=workspace_map.get(int(user.id), []),
|
||||
pending_review_count=pending_reviews_by_user.get(int(user.id), 0),
|
||||
active_execution_count=active_execution_by_user.get(int(user.id), 0),
|
||||
evidence_count=len(truth_state.evidence),
|
||||
)
|
||||
for user in members
|
||||
]
|
||||
|
||||
return PMFlowExecutionCapabilitySnapshot(
|
||||
projectId=str(project_id),
|
||||
executionModes=self._execution_modes(
|
||||
project_id=project_id,
|
||||
has_repo=bool(execution_summary.repoBindings),
|
||||
has_prompt=bool(execution_summary.promptPackage),
|
||||
has_progress=bool(execution_summary.recentProgressEvents),
|
||||
missing_reviewer_count=truth_state.reviewerRouting.summary.missingReviewerCount,
|
||||
pending_review_count=truth_state.reviewerRouting.summary.totalPendingReview,
|
||||
),
|
||||
personalReadiness=personal_readiness,
|
||||
teamSignals=team_signals,
|
||||
adaptiveCapacitySummary=adaptive_capacity,
|
||||
warnings=warnings[:12],
|
||||
)
|
||||
|
||||
async def _load_project_members(self, *, project_id: int) -> list[User]:
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(User)
|
||||
.join(UserProject, UserProject.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.tenant_id == self.tenant_id,
|
||||
UserProject.tenant_id == self.tenant_id,
|
||||
UserProject.project_id == int(project_id),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
.order_by(User.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
async def _load_skill_map(self, *, member_ids: list[int]) -> dict[int, list[str]]:
|
||||
if not member_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(UserSkillBinding.user_id, SkillTag.name)
|
||||
.join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id)
|
||||
.where(
|
||||
and_(
|
||||
UserSkillBinding.tenant_id == self.tenant_id,
|
||||
SkillTag.tenant_id == self.tenant_id,
|
||||
UserSkillBinding.user_id.in_(member_ids),
|
||||
)
|
||||
)
|
||||
.order_by(UserSkillBinding.user_id.asc(), SkillTag.sort_order.asc(), SkillTag.name.asc())
|
||||
)
|
||||
).all()
|
||||
skill_map: dict[int, list[str]] = defaultdict(list)
|
||||
for user_id, skill_name in rows:
|
||||
if skill_name and len(skill_map[int(user_id)]) < 5:
|
||||
skill_map[int(user_id)].append(str(skill_name))
|
||||
return dict(skill_map)
|
||||
|
||||
async def _load_workload_map(self, *, member_ids: list[int]) -> dict[int, float]:
|
||||
if not member_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(UserWorkload.user_id, UserWorkload.workload_ratio).where(
|
||||
and_(
|
||||
UserWorkload.tenant_id == self.tenant_id,
|
||||
UserWorkload.user_id.in_(member_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return {int(user_id): self._safe_float(ratio) for user_id, ratio in rows}
|
||||
|
||||
async def _load_workspace_map(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]:
|
||||
if not member_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where(
|
||||
and_(
|
||||
DevWorkspaceBinding.tenant_id == self.tenant_id,
|
||||
DevWorkspaceBinding.project_id == int(project_id),
|
||||
DevWorkspaceBinding.user_id.in_(member_ids),
|
||||
DevWorkspaceBinding.status == "active",
|
||||
)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
workspace_map: dict[int, list[str]] = defaultdict(list)
|
||||
for user_id, editor_type in rows:
|
||||
label = self._tool_label(str(editor_type or "local_bridge"))
|
||||
if label not in workspace_map[int(user_id)]:
|
||||
workspace_map[int(user_id)].append(label)
|
||||
return dict(workspace_map)
|
||||
|
||||
def _personal_readiness(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
current_user_id: int,
|
||||
truth_state: Any,
|
||||
execution_summary: Any,
|
||||
workspace_map: dict[int, list[str]],
|
||||
) -> PMFlowPersonalExecutionReadiness:
|
||||
pending_confirmations = len([item for item in truth_state.changeItems if item.status == "pending_review"])
|
||||
pending_reviews = len(
|
||||
[
|
||||
item
|
||||
for item in truth_state.reviewerRouting.items
|
||||
if item.suggestedReviewer and str(item.suggestedReviewer.userId or "") == str(current_user_id)
|
||||
]
|
||||
)
|
||||
waiting_acceptance = len(
|
||||
[
|
||||
event
|
||||
for event in execution_summary.recentProgressEvents
|
||||
if str(event.get("event_type") or "") in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed"}
|
||||
]
|
||||
)
|
||||
personal_tools = workspace_map.get(int(current_user_id), [])
|
||||
tool_readiness = [
|
||||
PMFlowToolReadinessItem(
|
||||
key="repo",
|
||||
label="Repo 连接",
|
||||
status="ready" if execution_summary.repoBindings else "blocked",
|
||||
summary="已接入代码仓库。" if execution_summary.repoBindings else "未接入 repo,AI 执行包缺少真实代码落点。",
|
||||
href=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
PMFlowToolReadinessItem(
|
||||
key="prompt_package",
|
||||
label="执行 Prompt Package",
|
||||
status="ready" if execution_summary.promptPackage else "partial",
|
||||
summary="已有可交给 Cursor / VS Code / Codex 的执行包。"
|
||||
if execution_summary.promptPackage
|
||||
else "尚未生成执行包,需要先从任务进入 Execution Bridge。",
|
||||
href=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
PMFlowToolReadinessItem(
|
||||
key="local_workspace",
|
||||
label="本地工具连接",
|
||||
status="ready" if personal_tools else "unknown",
|
||||
summary=f"已连接:{' / '.join(personal_tools)}。" if personal_tools else "未发现当前用户的活跃 Cursor / VS Code / Codex 工作区连接。",
|
||||
href=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
PMFlowToolReadinessItem(
|
||||
key="execution_evidence",
|
||||
label="执行证据回写",
|
||||
status="ready" if execution_summary.recentProgressEvents else "partial",
|
||||
summary="已有 branch / commit / PR / test 等回写证据。"
|
||||
if execution_summary.recentProgressEvents
|
||||
else "尚未回写执行证据,完成 IDE/Agent 操作后需要同步进度。",
|
||||
href=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
]
|
||||
blockers = [
|
||||
item.summary
|
||||
for item in tool_readiness
|
||||
if item.status == "blocked" or (item.key in {"prompt_package", "execution_evidence"} and item.status != "ready")
|
||||
][:4]
|
||||
if pending_reviews > 0:
|
||||
recommended = "先处理分配给你的审查项,再继续推进 AI 执行。"
|
||||
target = f"/change-review?projectId={project_id}"
|
||||
elif pending_confirmations > 0:
|
||||
recommended = "先确认 AI 生成的 ChangeSet,避免执行包基于未确认范围继续扩散。"
|
||||
target = f"/change-review?projectId={project_id}"
|
||||
elif blockers:
|
||||
recommended = "先补齐执行桥的 repo、执行包或回写证据。"
|
||||
target = f"/execution-bridge?projectId={project_id}"
|
||||
else:
|
||||
recommended = "可以继续生成或验收 AI 执行包。"
|
||||
target = f"/execution-bridge?projectId={project_id}"
|
||||
status = "blocked" if any(item.status == "blocked" for item in tool_readiness) else "partial" if blockers or pending_confirmations else "ready"
|
||||
return PMFlowPersonalExecutionReadiness(
|
||||
status=status,
|
||||
toolReadiness=tool_readiness,
|
||||
waitingForUser={
|
||||
"pendingConfirmations": pending_confirmations,
|
||||
"pendingReviews": pending_reviews,
|
||||
"waitingAcceptance": waiting_acceptance,
|
||||
},
|
||||
blockers=blockers,
|
||||
recommendedNextStep=recommended,
|
||||
targetHref=target,
|
||||
)
|
||||
|
||||
def _member_signal(
|
||||
self,
|
||||
*,
|
||||
user: User,
|
||||
project_id: int,
|
||||
skills: list[str],
|
||||
workload_ratio: float,
|
||||
workspace_tools: list[str],
|
||||
pending_review_count: int,
|
||||
active_execution_count: int,
|
||||
evidence_count: int,
|
||||
) -> PMFlowTeamExecutionSignal:
|
||||
role = str(user.role or "MEMBER")
|
||||
capacity_status = "blocked" if workload_ratio >= 100 else "partial" if workload_ratio >= 70 or pending_review_count >= 3 else "ready"
|
||||
capacity_label = "过载" if capacity_status == "blocked" else "偏紧" if capacity_status == "partial" else "可承接"
|
||||
context_status = "partial" if skills or evidence_count else "unknown"
|
||||
tool_status = "ready" if workspace_tools else "unknown"
|
||||
warnings: list[str] = []
|
||||
if not workspace_tools:
|
||||
warnings.append("未发现活跃 IDE / Agent 工具连接。")
|
||||
if not skills:
|
||||
warnings.append("缺少能力参考标签;只能依据角色、审查和执行证据判断。")
|
||||
if capacity_status == "blocked":
|
||||
warnings.append("容量过载,不建议继续追加高风险执行任务。")
|
||||
|
||||
return PMFlowTeamExecutionSignal(
|
||||
userId=str(user.id),
|
||||
displayName=user.name or user.username,
|
||||
role=role,
|
||||
judgementResponsibilities=self._judgement_responsibilities(role),
|
||||
reviewResponsibilities=self._review_responsibilities(role),
|
||||
capacity=PMFlowTeamCapacitySignal(
|
||||
status=capacity_status,
|
||||
label=capacity_label,
|
||||
workloadRatio=round(workload_ratio, 2),
|
||||
pendingReviewCount=pending_review_count,
|
||||
activeExecutionCount=active_execution_count,
|
||||
),
|
||||
context=PMFlowTeamContextSignal(
|
||||
status=context_status,
|
||||
label="有项目证据或能力参考" if context_status == "partial" else "证据不足",
|
||||
evidenceCount=evidence_count,
|
||||
recentContributionCount=pending_review_count + active_execution_count,
|
||||
),
|
||||
toolStatus=PMFlowTeamToolSignal(
|
||||
status=tool_status,
|
||||
label=" / ".join(workspace_tools) if workspace_tools else "未接入",
|
||||
connectedTools=workspace_tools,
|
||||
),
|
||||
referenceSkills=skills,
|
||||
recommendedModes=self._recommended_modes(role=role, capacity_status=capacity_status, has_tools=bool(workspace_tools)),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _execution_modes(
|
||||
*,
|
||||
project_id: int,
|
||||
has_repo: bool,
|
||||
has_prompt: bool,
|
||||
has_progress: bool,
|
||||
missing_reviewer_count: int,
|
||||
pending_review_count: int,
|
||||
) -> list[PMFlowExecutionModeCapability]:
|
||||
return [
|
||||
PMFlowExecutionModeCapability(
|
||||
mode="ai_direct",
|
||||
label="AI 直接生成执行包",
|
||||
status="ready" if has_repo and has_prompt else "partial" if has_repo else "blocked",
|
||||
reason="适合低风险、边界清晰的开发动作;必须有 repo 和执行包。",
|
||||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
PMFlowExecutionModeCapability(
|
||||
mode="ai_with_user_acceptance",
|
||||
label="AI 执行 + 用户验收",
|
||||
status="ready" if has_repo else "blocked",
|
||||
reason="适合非技术用户推进 UI、文案、轻量功能,用户负责验收结果。",
|
||||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
PMFlowExecutionModeCapability(
|
||||
mode="ai_with_technical_review",
|
||||
label="AI 执行 + 技术审查",
|
||||
status="blocked" if missing_reviewer_count > 0 else "ready" if pending_review_count > 0 or has_repo else "partial",
|
||||
reason="涉及权限、数据、安全、迁移或发布门禁时必须进入审查链。",
|
||||
targetHref=f"/team-layer?projectId={project_id}",
|
||||
),
|
||||
PMFlowExecutionModeCapability(
|
||||
mode="needs_human_judgement",
|
||||
label="需要人类先补判断",
|
||||
status="ready" if pending_review_count > 0 else "partial",
|
||||
reason="目标、范围、风险或商业取舍未确认时,先回到 Change Review。",
|
||||
targetHref=f"/change-review?projectId={project_id}",
|
||||
),
|
||||
PMFlowExecutionModeCapability(
|
||||
mode="blocked",
|
||||
label="当前不可执行",
|
||||
status="ready" if not has_repo or not has_prompt or not has_progress or missing_reviewer_count > 0 else "unknown",
|
||||
reason="缺 repo、执行包、回写证据或 reviewer 时必须显式阻塞,不冒充分配成功。",
|
||||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _pending_review_counts_by_user(items: list[Any]) -> dict[int, int]:
|
||||
counts: dict[int, int] = defaultdict(int)
|
||||
for item in items:
|
||||
reviewer = getattr(item, "suggestedReviewer", None)
|
||||
user_id = getattr(reviewer, "userId", None) if reviewer else None
|
||||
if user_id is not None:
|
||||
counts[int(user_id)] += 1
|
||||
return dict(counts)
|
||||
|
||||
@staticmethod
|
||||
def _active_execution_counts_by_user(tasks: list[dict[str, Any]]) -> dict[int, int]:
|
||||
counts: dict[int, int] = defaultdict(int)
|
||||
for task in tasks:
|
||||
assignee = task.get("assignee") if isinstance(task, dict) else None
|
||||
if isinstance(assignee, dict) and assignee.get("id") is not None:
|
||||
counts[int(assignee["id"])] += 1
|
||||
return dict(counts)
|
||||
|
||||
@staticmethod
|
||||
def _judgement_responsibilities(role: str) -> list[str]:
|
||||
normalized = role.upper()
|
||||
if normalized == "OWNER":
|
||||
return ["目标取舍", "范围确认", "发布拍板", "高风险变更最终责任"]
|
||||
if normalized == "REVIEWER":
|
||||
return ["技术/产品审查", "风险判断", "发布门禁建议"]
|
||||
return ["结果验收", "上下文补充", "低风险变更确认"]
|
||||
|
||||
@staticmethod
|
||||
def _review_responsibilities(role: str) -> list[str]:
|
||||
normalized = role.upper()
|
||||
if normalized == "OWNER":
|
||||
return ["高风险 ChangeSet", "范围/发布/商业取舍"]
|
||||
if normalized == "REVIEWER":
|
||||
return ["实现风险", "架构/测试/权限审查"]
|
||||
return ["本人相关任务验收"]
|
||||
|
||||
@staticmethod
|
||||
def _recommended_modes(*, role: str, capacity_status: str, has_tools: bool) -> list[PMFlowExecutionCapabilityMode]:
|
||||
if capacity_status == "blocked":
|
||||
return ["needs_human_judgement"]
|
||||
normalized = role.upper()
|
||||
if normalized in {"OWNER", "REVIEWER"}:
|
||||
return ["ai_with_technical_review", "needs_human_judgement"]
|
||||
if has_tools:
|
||||
return ["ai_with_user_acceptance", "ai_direct"]
|
||||
return ["ai_with_user_acceptance"]
|
||||
|
||||
@staticmethod
|
||||
def _tool_label(editor_type: str) -> str:
|
||||
normalized = editor_type.lower()
|
||||
if "cursor" in normalized:
|
||||
return "Cursor"
|
||||
if "codex" in normalized:
|
||||
return "Codex"
|
||||
if "vscode" in normalized or "vs_code" in normalized:
|
||||
return "VS Code"
|
||||
return editor_type or "本地桥"
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.api import ai_routing
|
||||
from app.core.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.dependencies.tenant import get_tenant_id
|
||||
from app.models.ai_call_log import AICallLog
|
||||
from app.models.base import Base
|
||||
from app.models.integration import Integration
|
||||
from app.services.ai_routing_governance_service import AIRoutingGovernanceService
|
||||
|
||||
|
||||
def _use_sqlite_json_types(*tables) -> None:
|
||||
for table in tables:
|
||||
for column in table.columns:
|
||||
if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
tables = [Integration.__table__, AICallLog.__table__]
|
||||
_use_sqlite_json_types(*tables)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables))
|
||||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with maker() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _engine_bundle() -> dict:
|
||||
return {
|
||||
"active_engine_id": "low-1",
|
||||
"settings": {"enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only"},
|
||||
"engines": [
|
||||
{
|
||||
"id": "low-1",
|
||||
"name": "Qwen 低阶引擎",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key": "db-low-key",
|
||||
},
|
||||
{
|
||||
"id": "high-1",
|
||||
"name": "OpenAI 高阶引擎",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"base_url": "https://api.edgefn.net/v1",
|
||||
"api_key": "db-high-key",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overview_masks_keys_and_reports_env_override(db_session, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DASHSCOPE_API_KEY", "local-placeholder")
|
||||
db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True))
|
||||
await db_session.commit()
|
||||
|
||||
data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_overview()
|
||||
|
||||
assert data["config_version"]
|
||||
assert data["slots"][0]["label"] == "快速槽"
|
||||
assert data["slots"][0]["api_key_masked"] == "db-l...-key"
|
||||
assert any(item["code"] == "env_key_override" for item in data["diagnostics"])
|
||||
assert "api_env" not in str(data)
|
||||
assert "local-placeholder" not in str(data)
|
||||
assert "db-low-key" not in str(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_console_reports_protected_downgrade_and_structured_failures(db_session) -> None:
|
||||
db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True))
|
||||
db_session.add(
|
||||
Integration(
|
||||
tenant_id=1,
|
||||
type="model_routing",
|
||||
config={"mappings": {"adaptive_rag_answer_deep": {"tier_alias": "low", "enabled": True}}},
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
for idx in range(3):
|
||||
db_session.add(
|
||||
AICallLog(
|
||||
tenant_id=1,
|
||||
function_name="pmflow_launch_draft_v1",
|
||||
prompt=f"prompt {idx}",
|
||||
response="",
|
||||
tokens_used=1,
|
||||
cost=0,
|
||||
status="structured_empty",
|
||||
planned_tier="low",
|
||||
executed_tier="low",
|
||||
routing_decision="rule_match",
|
||||
route_source="default",
|
||||
route_rule_name="engine_low_fallback",
|
||||
routing_config_version="v3",
|
||||
routing_config_updated_at="2026-03-26T21:30:00+08:00",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_admin_console()
|
||||
|
||||
codes = {item["code"] for item in data["diagnostics"]}
|
||||
assert "protected_function_downgraded" in codes
|
||||
assert "structured_failure_rate" in codes
|
||||
assert data["recent_failures"][0]["status_label"] == "模型返回为空"
|
||||
assert data["scene_routes"][0]["label"] == "项目启动草案"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_policy_persists_integration_without_runtime_route_changes(db_session) -> None:
|
||||
service = AIRoutingGovernanceService(db_session, tenant_id=1)
|
||||
payload = {
|
||||
"strategy": "quality_first",
|
||||
"scenes": [{"key": "project_launch", "policy": "深度起草并进入确认", "mode": "深度优先"}],
|
||||
"automation_boundary": "所有写操作进入待确认。",
|
||||
}
|
||||
|
||||
saved = await service.update_team_policy(payload=payload, updated_by=7)
|
||||
await db_session.commit()
|
||||
loaded = await service.get_team_policy()
|
||||
|
||||
assert saved["strategy_label"] == "质量优先"
|
||||
assert loaded["scenes"][0]["policy"] == "深度起草并进入确认"
|
||||
assert loaded["automation_boundary"] == "所有写操作进入待确认。"
|
||||
|
||||
|
||||
def _build_app(*, role: str = "REVIEWER") -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(ai_routing.router)
|
||||
|
||||
class FakeDb:
|
||||
async def commit(self) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_get_db():
|
||||
yield FakeDb()
|
||||
|
||||
app.dependency_overrides[get_db] = _fake_get_db
|
||||
app.dependency_overrides[get_tenant_id] = lambda: 9
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=101,
|
||||
tenant_id=9,
|
||||
username="tester",
|
||||
role=role,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def test_api_router_exposes_ai_routing_routes() -> None:
|
||||
router_source = (Path(__file__).parents[1] / "app" / "api" / "router.py").read_text()
|
||||
router_tree = ast.parse(router_source)
|
||||
route_assignments = {}
|
||||
for node in router_tree.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in {"CORE_ROUTE_MODULES", "OPTIONAL_ROUTE_GROUPS"}:
|
||||
route_assignments[target.id] = ast.literal_eval(node.value)
|
||||
|
||||
assert "ai_routing" in route_assignments["CORE_ROUTE_MODULES"]
|
||||
assert "model_routing" in route_assignments["CORE_ROUTE_MODULES"]
|
||||
assert "ai_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"]
|
||||
assert "model_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"]
|
||||
route_paths = {str(getattr(route, "path", "")).strip() for route in ai_routing.router.routes}
|
||||
assert "/ai-routing/overview" in route_paths
|
||||
assert "/ai-routing/team-policy" in route_paths
|
||||
assert "/ai-routing/admin-console" in route_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_routing_overview_requires_governance_role() -> None:
|
||||
app = _build_app(role="BUILDER")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get("/ai-routing/overview")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_routing_api_returns_enveloped_payloads(monkeypatch) -> None:
|
||||
class FakeService:
|
||||
def __init__(self, db, *, tenant_id: int):
|
||||
assert tenant_id == 9
|
||||
_ = db
|
||||
|
||||
async def get_overview(self):
|
||||
return {"slots": [], "diagnostics": []}
|
||||
|
||||
async def get_team_policy(self):
|
||||
return {"strategy": "balanced", "scenes": []}
|
||||
|
||||
async def update_team_policy(self, *, payload, updated_by: int):
|
||||
assert updated_by == 101
|
||||
return {**payload, "updated_by": updated_by}
|
||||
|
||||
async def get_admin_console(self):
|
||||
return {"providers": [], "scene_routes": []}
|
||||
|
||||
async def run_diagnostics(self):
|
||||
return {"items": [], "summary": {"ok": 1}}
|
||||
|
||||
async def fake_log_operation(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
|
||||
monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService)
|
||||
monkeypatch.setattr(ai_routing, "log_operation", fake_log_operation)
|
||||
|
||||
app = _build_app(role="OWNER")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
overview = await client.get("/ai-routing/overview")
|
||||
policy = await client.get("/ai-routing/team-policy")
|
||||
saved = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []})
|
||||
console = await client.get("/ai-routing/admin-console")
|
||||
diagnostics = await client.post("/ai-routing/diagnostics/run")
|
||||
|
||||
assert overview.json()["code"] == 0
|
||||
assert policy.json()["data"]["strategy"] == "balanced"
|
||||
assert saved.json()["data"]["strategy"] == "quality_first"
|
||||
assert console.json()["data"]["providers"] == []
|
||||
assert diagnostics.json()["data"]["summary"]["ok"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_cannot_save_team_policy(monkeypatch) -> None:
|
||||
class FakeService:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService)
|
||||
app = _build_app(role="REVIEWER")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["message"] == "仅 OWNER 可保存团队 AI 策略"
|
||||
|
|
@ -2,97 +2,52 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api import legacy_ai_steward_api
|
||||
from app.api.ai_steward_api import routes_capture
|
||||
from app.api.ai_steward_api.schemas import ProjectFromTextResponse
|
||||
from app.core.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.dependencies.tenant import get_tenant_id
|
||||
from app.services.ai_steward_service import mixin_creation_normalize_persist
|
||||
from app.services.ai_steward_service import AIStewardService
|
||||
from app.services.task_persistence_core import SkillTagResolution
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -> None:
|
||||
async def fake_create_project_from_natural_language(self, **kwargs): # noqa: ANN001
|
||||
_ = (self, kwargs)
|
||||
return {
|
||||
"project": {"id": "11", "name": "AI 客服", "title": "AI 客服平台", "description": "demo", "status": "active"},
|
||||
"signal": {"id": "21", "content": "main signal", "impact_score": 0.91},
|
||||
"charter": {"id": "31", "title": "宪章", "content": {"summary": "demo"}},
|
||||
"tasks": [],
|
||||
"risks": [],
|
||||
"analysis": {
|
||||
"project_name": "AI 客服",
|
||||
"project_title": "AI 客服平台",
|
||||
"tech_stack": ["FastAPI", "React"],
|
||||
"timeline": "3 周",
|
||||
"primary_role": "backend",
|
||||
"requirement_items": [
|
||||
{
|
||||
"section": "核心痛点",
|
||||
"title": "内容本地化",
|
||||
"description": "翻译公司贵、慢、不地道",
|
||||
}
|
||||
],
|
||||
},
|
||||
"signal_sources": [
|
||||
{
|
||||
"id": "101",
|
||||
"source_kind": "custom",
|
||||
"source_name": "核心痛点 - 内容本地化",
|
||||
"source_identifier": "custom:core-pain-content-localization",
|
||||
"display_source_kind": "自定义来源",
|
||||
"technical_identifier": "custom:core-pain-content-localization",
|
||||
"description": "翻译公司贵、慢、不地道",
|
||||
"section": "核心痛点",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def _fake_get_db():
|
||||
yield object()
|
||||
|
||||
monkeypatch.setattr(AIStewardService, "create_project_from_natural_language", fake_create_project_from_natural_language)
|
||||
|
||||
async def test_project_from_text_returns_gone_envelope() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(routes_capture.router)
|
||||
app.dependency_overrides[get_db] = _fake_get_db
|
||||
app.dependency_overrides[get_tenant_id] = lambda: 1
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=9,
|
||||
tenant_id=1,
|
||||
name="管理员",
|
||||
username="admin",
|
||||
role="REVIEWER",
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/ai-steward/project-from-text", json={"input_text": "我们要做一个 AI 客服工具"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 410
|
||||
payload = response.json()
|
||||
assert payload["code"] == 0
|
||||
assert payload["message"] == "success"
|
||||
assert payload["data"]["project"]["id"] == "11"
|
||||
assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化"
|
||||
assert payload["data"]["signal_sources"][0]["display_source_kind"] == "自定义来源"
|
||||
assert payload["data"]["signal_sources"][0]["technical_identifier"] == "custom:core-pain-content-localization"
|
||||
assert payload["code"] == 410
|
||||
assert payload["message"] == "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"
|
||||
assert payload["data"] is None
|
||||
|
||||
|
||||
def test_project_from_text_response_schema_exposes_signal_source_display_fields() -> None:
|
||||
schema = ProjectFromTextResponse.model_json_schema()
|
||||
signal_source = schema["$defs"]["ProjectFromTextSignalSource"]
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_project_from_text_gone_route_is_core_mounted() -> None:
|
||||
router_source = Path("app/api/router.py").read_text(encoding="utf-8")
|
||||
assert '"legacy_ai_steward_api"' in router_source
|
||||
|
||||
assert "display_source_kind" in signal_source["properties"]
|
||||
assert "technical_identifier" in signal_source["properties"]
|
||||
app = FastAPI()
|
||||
app.include_router(legacy_ai_steward_api.router)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/ai-steward/project-from-text", json={"input_text": "我们要做一个 AI 客服工具"})
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {
|
||||
"code": 410,
|
||||
"message": "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def test_ai_steward_no_longer_keeps_instruction_or_stub_heuristics_in_runtime_modules() -> None:
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ def _find_route(items: list[dict], route: str) -> dict:
|
|||
|
||||
def test_default_feature_catalog_uses_canonical_rag_ask() -> None:
|
||||
items = ContextCacheProfileBasicsMixin._default_feature_catalog_items()
|
||||
ai_steward_item = _find_route(items, "/ai-steward")
|
||||
apis = list(ai_steward_item.get("apis") or [])
|
||||
space_item = _find_route(items, "/space")
|
||||
apis = list(space_item.get("apis") or [])
|
||||
assert "/rag/ask" in apis
|
||||
assert "/ai-steward/rag/ask" not in apis
|
||||
|
||||
|
||||
def test_route_feature_summary_uses_canonical_rag_ask() -> None:
|
||||
_summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward")
|
||||
_summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/space")
|
||||
assert "/rag/ask" in apis
|
||||
assert "/ai-steward/rag/ask" not in apis
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from app.models import ( # noqa: E402
|
|||
FeedbackIngest,
|
||||
FeishuItem,
|
||||
GitHubWebhookDelivery,
|
||||
IterationRecommendation,
|
||||
ModelCatalogItem,
|
||||
ModelEvalRun,
|
||||
ModelRecommendationSnapshot,
|
||||
|
|
@ -80,6 +81,7 @@ TABLES = [
|
|||
ProgressEvent.__table__,
|
||||
ReleaseValidationRun.__table__,
|
||||
FeedbackIngest.__table__,
|
||||
IterationRecommendation.__table__,
|
||||
RevenueEvent.__table__,
|
||||
ROISnapshot.__table__,
|
||||
GitHubWebhookDelivery.__table__,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
|
@ -21,6 +21,7 @@ from app.models import ( # noqa: E402
|
|||
FeedbackIngest,
|
||||
FeishuItem,
|
||||
GitHubWebhookDelivery,
|
||||
HumanCapacityOverride,
|
||||
IterationRecommendation,
|
||||
ModelCatalogItem,
|
||||
ModelEvalRun,
|
||||
|
|
@ -47,12 +48,19 @@ from app.models import ( # noqa: E402
|
|||
TruthNode,
|
||||
User,
|
||||
UserProject,
|
||||
SkillTag,
|
||||
UserSkillBinding,
|
||||
UserWorkload,
|
||||
)
|
||||
from app.models.base import Base # noqa: E402
|
||||
|
||||
|
||||
TABLES = [
|
||||
User.__table__,
|
||||
SkillTag.__table__,
|
||||
UserSkillBinding.__table__,
|
||||
UserWorkload.__table__,
|
||||
HumanCapacityOverride.__table__,
|
||||
FeishuItem.__table__,
|
||||
Signal.__table__,
|
||||
Project.__table__,
|
||||
|
|
@ -150,6 +158,66 @@ def _build_app(session, current_user) -> FastAPI:
|
|||
return app
|
||||
|
||||
|
||||
async def _seed_open_change_set(session, *, user, project, risk_level: str = "high", item_count: int = 1) -> None:
|
||||
thread = PMFlowThread(
|
||||
tenant_id=1,
|
||||
project_id=project.id,
|
||||
launch_draft_id=None,
|
||||
title="容量计划测试线程",
|
||||
objective="构造真实 ChangeSet 人工触点",
|
||||
status="active",
|
||||
source=f"capacity-test-{risk_level}-{item_count}",
|
||||
)
|
||||
session.add(thread)
|
||||
await session.flush()
|
||||
message = PMFlowThreadMessage(
|
||||
tenant_id=1,
|
||||
thread_id=thread.id,
|
||||
project_id=project.id,
|
||||
role="user",
|
||||
content="需要人工确认容量触点。",
|
||||
source="web",
|
||||
attachments=[],
|
||||
message_metadata={},
|
||||
status="sent",
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
change_set = PMFlowChangeSet(
|
||||
tenant_id=1,
|
||||
project_id=project.id,
|
||||
thread_id=thread.id,
|
||||
source_message_id=message.id,
|
||||
status="open",
|
||||
summary="容量计划 ChangeSet",
|
||||
headline="容量计划 ChangeSet",
|
||||
extraction_scope="latest_message",
|
||||
routing_meta={},
|
||||
created_by=user.id,
|
||||
)
|
||||
session.add(change_set)
|
||||
await session.flush()
|
||||
for index in range(item_count):
|
||||
session.add(
|
||||
PMFlowChangeItem(
|
||||
tenant_id=1,
|
||||
change_set_id=change_set.id,
|
||||
project_id=project.id,
|
||||
group="risk" if risk_level == "high" else "field_update",
|
||||
object_type="Risk" if risk_level == "high" else "Task",
|
||||
object_id=None,
|
||||
title=f"容量触点 {index + 1}",
|
||||
summary="用于验证动态容量基线。",
|
||||
diff="pending review",
|
||||
payload={},
|
||||
confidence=0.9,
|
||||
risk_level=risk_level,
|
||||
status="pending_review",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truth_state_empty_project_returns_compatible_empty_projection(db_env) -> None:
|
||||
session = db_env
|
||||
|
|
@ -290,6 +358,192 @@ async def test_execution_summary_empty_state_is_pending_with_warnings(db_env) ->
|
|||
assert payload["activeDevTasks"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_capabilities_returns_read_only_snapshot_with_warnings(db_env) -> None:
|
||||
session = db_env
|
||||
user, project = await _seed_user_project(session)
|
||||
skill = SkillTag(
|
||||
tenant_id=1,
|
||||
name="产品判断",
|
||||
slug="product-judgement",
|
||||
category="product",
|
||||
description="判断需求范围与验收结果",
|
||||
is_system=True,
|
||||
sort_order=1,
|
||||
)
|
||||
session.add(skill)
|
||||
await session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
UserSkillBinding(
|
||||
tenant_id=1,
|
||||
user_id=user.id,
|
||||
skill_tag_id=skill.id,
|
||||
proficiency_level="advanced",
|
||||
proficiency=4,
|
||||
level="advanced",
|
||||
years_experience=2,
|
||||
),
|
||||
UserWorkload(
|
||||
tenant_id=1,
|
||||
user_id=user.id,
|
||||
current_assigned_hours=18,
|
||||
max_available_hours_per_week=40,
|
||||
workload_ratio=45,
|
||||
),
|
||||
DevWorkspaceBinding(
|
||||
tenant_id=1,
|
||||
project_id=project.id,
|
||||
user_id=user.id,
|
||||
workspace_key="cursor-local",
|
||||
repo_root="/tmp/pmflow",
|
||||
editor_type="cursor",
|
||||
status="active",
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-capabilities")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["projectId"] == str(project.id)
|
||||
assert {item["mode"] for item in payload["executionModes"]} >= {
|
||||
"ai_direct",
|
||||
"ai_with_user_acceptance",
|
||||
"ai_with_technical_review",
|
||||
"needs_human_judgement",
|
||||
"blocked",
|
||||
}
|
||||
assert payload["personalReadiness"]["waitingForUser"]["pendingConfirmations"] == 0
|
||||
assert payload["teamSignals"][0]["displayName"] == "Owner"
|
||||
assert payload["teamSignals"][0]["toolStatus"]["connectedTools"] == ["Cursor"]
|
||||
assert payload["teamSignals"][0]["referenceSkills"] == ["产品判断"]
|
||||
assert "repo_missing" in " ".join(payload["warnings"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_capabilities_requires_project_membership(db_env) -> None:
|
||||
session = db_env
|
||||
user, project = await _seed_user_project(session, user_role="MEMBER", member=False)
|
||||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-capabilities")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capacity_plan_uses_current_touch_pool_for_dynamic_baseline(db_env) -> None:
|
||||
session = db_env
|
||||
user, project = await _seed_user_project(session, user_role="OWNER")
|
||||
await _seed_open_change_set(session, user=user, project=project, risk_level="high", item_count=3)
|
||||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER"))
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
member_plan = payload["memberPlans"][0]
|
||||
assert payload["teamPlannedTouchMinutes"]["focus"] > 0
|
||||
assert member_plan["suggestedBaseline"]["focus"] > member_plan["suggestedBaseline"]["acceptance"]
|
||||
assert member_plan["plannedTouchMinutes"]["total"] > 0
|
||||
assert "动态基线来自当前任务池" in " ".join(member_plan["explanations"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capacity_override_simulate_apply_and_permission_boundary(db_env) -> None:
|
||||
session = db_env
|
||||
owner, project = await _seed_user_project(session, user_role="OWNER")
|
||||
member = User(
|
||||
tenant_id=1,
|
||||
username="capacity-member",
|
||||
email="capacity-member@example.com",
|
||||
password_hash="hashed",
|
||||
name="普通成员",
|
||||
role="MEMBER",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(member)
|
||||
await session.flush()
|
||||
session.add(UserProject(tenant_id=1, user_id=member.id, project_id=project.id))
|
||||
await session.commit()
|
||||
payload = {
|
||||
"userId": str(owner.id),
|
||||
"overrideType": "overtime",
|
||||
"addedMinutes": 120,
|
||||
"overtimeType": "focus",
|
||||
"maxPlannedUtilization": 0.95,
|
||||
"reason": "今天需要负责人兜住高风险判断",
|
||||
}
|
||||
|
||||
owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="OWNER"))
|
||||
async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client:
|
||||
simulated = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides/simulate", json=payload)
|
||||
applied = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides", json=payload)
|
||||
plan = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan")
|
||||
|
||||
assert simulated.status_code == 200
|
||||
assert simulated.json()["data"]["afterPlan"]["effectiveMinutes"] == 600
|
||||
assert "加班只增加时间预算" in " ".join(simulated.json()["data"]["impactSummary"])
|
||||
assert applied.status_code == 200
|
||||
assert applied.json()["data"]["reason"] == payload["reason"]
|
||||
member_plan = next(item for item in plan.json()["data"]["memberPlans"] if item["userId"] == str(owner.id))
|
||||
assert member_plan["effectiveMinutes"] == 600
|
||||
assert member_plan["manualOverride"]["overrideType"] == "overtime"
|
||||
|
||||
member_app = _build_app(session, SimpleNamespace(id=member.id, tenant_id=1, role="MEMBER"))
|
||||
async with AsyncClient(transport=ASGITransport(app=member_app), base_url="http://test") as client:
|
||||
forbidden = await client.post(f"/pmflow-os/projects/{project.id}/capacity-overrides", json=payload)
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_capacity_override_is_not_used_in_today_plan(db_env) -> None:
|
||||
session = db_env
|
||||
user, project = await _seed_user_project(session, user_role="OWNER")
|
||||
yesterday = datetime.now() - timedelta(days=1)
|
||||
session.add(
|
||||
HumanCapacityOverride(
|
||||
tenant_id=1,
|
||||
project_id=project.id,
|
||||
user_id=user.id,
|
||||
date=datetime.now().date(),
|
||||
override_type="overtime",
|
||||
added_minutes=180,
|
||||
overtime_type="general",
|
||||
max_planned_utilization=0.95,
|
||||
work_type_bias={},
|
||||
high_risk_limit=None,
|
||||
reason="已过期的加班安排",
|
||||
expires_at=yesterday,
|
||||
created_by=user.id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER"))
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get(f"/pmflow-os/projects/{project.id}/capacity-plan")
|
||||
overrides = await client.get(f"/pmflow-os/projects/{project.id}/capacity-overrides")
|
||||
|
||||
assert response.status_code == 200
|
||||
member_plan = response.json()["data"]["memberPlans"][0]
|
||||
assert member_plan["effectiveMinutes"] == 480
|
||||
assert member_plan["manualOverride"] is None
|
||||
assert overrides.status_code == 200
|
||||
assert overrides.json()["data"][0]["reason"] == "已过期的加班安排"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_summary_projects_real_prompt_repo_tasks_and_events(db_env) -> None:
|
||||
session = db_env
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
# Inbox 消息台内联操作(三期执行清单)
|
||||
|
||||
> 目标:用户在消息台完成待处理动作,尽量不跳转;实现过程中不绕开现有 AI PMO 状态机闭环。
|
||||
|
||||
## Phase 1(先落地,最小闭环)
|
||||
|
||||
### 范围
|
||||
- 前端内联处理 `missing_project_scope` 卡片。
|
||||
- 后端只做字段投影增强,不新增执行链路接口。
|
||||
|
||||
### 后端 Checklist
|
||||
- [x] `GET /ai-pmo/work-items` 增加可直接渲染字段:
|
||||
- [x] `latest_policy_gate_code`
|
||||
- [x] `approval_requirement`(至少含 `required/reason/message`)
|
||||
- [x] 字段来源明确为“最近一条 `decision_records` 投影”,禁止前端猜文案。
|
||||
- [x] 保持现有 `POST /ai-pmo/work-items/{id}/decision` 语义不变。
|
||||
|
||||
### 前端 Checklist
|
||||
- [x] 消息台卡片识别条件:
|
||||
- [x] `state=waiting_manual`
|
||||
- [x] `latest_policy_gate_code=missing_project_scope`
|
||||
- [x] 内联面板显示“项目下拉 + 确认关联”。
|
||||
- [x] 项目下拉数据源使用 `GET /projects?status=active`。
|
||||
- [x] 点击确认后调用:
|
||||
- [x] `POST /ai-pmo/work-items/{id}/decision`
|
||||
- [x] body: `{ action: "approve", project_id }`
|
||||
- [x] 成功后刷新消息台并显示 followup 回执文案。
|
||||
|
||||
### 验收
|
||||
- [x] 缺项目归属卡片可在消息台直接补项目并继续执行。
|
||||
- [x] 不新增 `resolve-project` 接口。
|
||||
- [x] 不直连 `TypedExecutor`。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2(扩展内联动作,不破坏闭环)
|
||||
|
||||
### 范围
|
||||
- 支持更多 `waiting_manual` 场景统一内联处理。
|
||||
- 负载预警改为复用现有 workload 接口。
|
||||
|
||||
### 后端 Checklist
|
||||
- [ ] `GET /ai-pmo/work-items` 继续补充前端所需稳定字段(如 `intent`、`execution_result` 关键子字段)。
|
||||
- [ ] 若需负责人相关动作,先定义 owner 写入目标(task/registry/work_item),再补接口,不先拍脑袋加 `assign-owner`。
|
||||
|
||||
### 前端 Checklist
|
||||
- [ ] 通用 `waiting_manual` 面板(非 missing_project_scope)支持:
|
||||
- [ ] 继续执行
|
||||
- [ ] 标记失效
|
||||
- [ ] 查看详情
|
||||
- [ ] 负载预警卡片实现方式:
|
||||
- [ ] 预览建议:`POST /workloads/ai-optimize`
|
||||
- [ ] 应用建议:`POST /workloads/ai-optimize/apply`
|
||||
- [ ] 禁止依赖不存在接口:`GET /workloads/suggestions`。
|
||||
|
||||
### 验收
|
||||
- [ ] 负载预警可在消息台触发建议预览与应用。
|
||||
- [ ] 所有动作仍通过既有审计/回执链路。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3(信号源治理与增强)
|
||||
|
||||
### 范围
|
||||
- 信号源相关内联治理动作补齐(编辑/删除/确认信息)。
|
||||
- 明确通用信号源 API 边界后再接入消息台按钮。
|
||||
|
||||
### 后端 Checklist
|
||||
- [ ] 先梳理“通用信号源 CRUD”真实接口归属(当前 `/signal-sources` 为食谱 webhook,不可直接复用)。
|
||||
- [ ] 若缺通用接口,按规范新增并补测试。
|
||||
- [ ] 补充操作审计(operation logs)与幂等约束。
|
||||
|
||||
### 前端 Checklist
|
||||
- [ ] 基于真实通用接口接入:
|
||||
- [ ] 编辑信号源
|
||||
- [ ] 删除信号源(确认弹窗)
|
||||
- [ ] 不在 `InboxPage` 内直接写 `fetch`,统一走 `frontend/src/api/`。
|
||||
|
||||
### 验收
|
||||
- [ ] 消息台信号源治理动作可用,且接口与模型一致。
|
||||
- [ ] 审计可追踪,状态可回放。
|
||||
|
||||
---
|
||||
|
||||
## 统一守则(每期都要过)
|
||||
|
||||
- [x] 不新增绕开 `ai_pmo` 闭环的执行入口。
|
||||
- [x] 不做“前端猜测字符串”分支判断,优先消费稳定结构字段。
|
||||
- [x] 变更后跑通:
|
||||
- [x] `npm run build`
|
||||
- [x] `tsc --noEmit`
|
||||
- [x] 相关后端测试(新增接口时必须补测)
|
||||
|
||||
## 回填记录(2026-03-12)
|
||||
|
||||
- Phase 1 状态:已完成并通过验证。
|
||||
- 后端验证:
|
||||
- `../venv/bin/python -m pytest -q tests/test_ai_pmo_work_item_decision_api.py` → `3 passed`
|
||||
- `../venv/bin/python -m py_compile app/api/ai_pmo.py` → 通过
|
||||
- 前端验证:
|
||||
- `npm run build` → 通过
|
||||
- `npx tsc --noEmit` → 通过
|
||||
- 热修(2026-03-12 同日):
|
||||
- `POST /ai-pmo/work-items/{id}/decision` 在 `waiting_manual + project_id` 场景改为“快速返回 + 后台续跑”,避免消息台按钮长期卡在“处理中...”
|
||||
- `POST /ai-pmo/work-items/{id}/decision` 在上述场景会先把目标 work item 状态置为 `executing`(前端即时从“待处理”转为“处理中”)
|
||||
- 前端 `decideWorkItem` 增加 15s 超时保护,超时会回到可交互状态并提示错误
|
||||
- 口水话(短 query,如“怎么样了/在吗/辛苦了”)在后端列表接口过滤,不再下发到消息台前端(数据仍保留在后端记录)
|
||||
- 修复信号源续跑事务穿透:`signal_source_registries` 重复键分支改为 savepoint,且 PMFlow 本地路由层 指挥链改为隔离 Session,避免 rollback 回滚外层新建 `signal`,彻底消除 `ai_orchestration_runs.signal_id` 外键报错
|
||||
- 修复“续跑后仍停在待人工确认”:`signal_source.onboard` 在 `pending_manual/signal_source_pending_manual` 且已具备 `project_id+signal_id` 时强制落 `succeeded`,并记录 `manual_gate_resolved=true`
|
||||
- Phase 2/3:未开始,保持待办。
|
||||
|
||||
## 本清单对应文件范围(执行时参考)
|
||||
|
||||
- `frontend/src/pages/InboxPage.tsx`
|
||||
- `frontend/src/components/inbox/`
|
||||
- `frontend/src/lib/inbox.ts`
|
||||
- `frontend/src/types/inbox.ts`
|
||||
- `frontend/src/api/aiPmo.ts`
|
||||
- `frontend/src/api/projects.ts`
|
||||
- `frontend/src/api/workloads.ts`
|
||||
- `backend/app/api/ai_pmo.py`
|
||||
|
|
@ -311,6 +311,36 @@ PMFlow 的团队方向是:
|
|||
|
||||
这比传统成员列表和任务看板更重要。
|
||||
|
||||
### 6.4 Execution Capability Graph / 执行能力图谱
|
||||
|
||||
PMFlow 面向的是 AI 应用开发,不再把“人会哪种编程语言”作为任务分配中心。旧的用户、技能、负载三模块降级为底层信号;新的团队与个人能力模型是:
|
||||
|
||||
**AI + 工具 + 人类判断责任 + 项目上下文 + 容量信号。**
|
||||
|
||||
任务推进先判断执行模式,而不是先找会 React / Python 的人:
|
||||
|
||||
- `ai_direct`:AI 可直接生成执行包,适合低风险、边界清晰的动作。
|
||||
- `ai_with_user_acceptance`:AI 执行,用户负责验收结果,适合非技术或半技术用户。
|
||||
- `ai_with_technical_review`:AI 执行但必须有人类 reviewer 审查,适合权限、数据、安全、迁移、发布门禁等高风险改动。
|
||||
- `needs_human_judgement`:范围、目标、商业取舍或验收标准不清时,先让人类补判断。
|
||||
- `blocked`:缺 repo、插件、执行包、测试命令、回写证据或 reviewer 时显式阻塞,禁止冒充可执行。
|
||||
|
||||
后续团队空间只能扩展此模型,不能回到传统“用户管理 / 技能管理 / 团队负载”三件套作为主体验。技能标签可以继续作为参考词库,但不能成为 AI 分配任务的唯一或核心依据。
|
||||
|
||||
### 6.5 Adaptive Human Capacity / 自适应人类容量
|
||||
|
||||
AI 应用时代不能继续把“工时负载”理解成固定工时槽或固定类型比例。PMFlow 的容量模型以人机混排为前提:
|
||||
|
||||
- 时间是硬上限:人类一天仍有有限注意力、会议、休息和疲劳边界。
|
||||
- 类型是弹性的:深度判断、审查、验收、协调共享同一个人类容量池,不再写死 35/25/20/20。
|
||||
- 风险和疲劳决定边界:总时间未满不代表可以继续加高风险判断;加班只增加时间预算,不消除质量风险。
|
||||
- 每日建议基线由当天真实任务池动态生成,依据人工触点、风险等级、截止时间、强绑定关系、成员角色、上下文熟悉度和工具准备度。
|
||||
- 负责人可以手工调整今日有效容量、加班容量和任务类型偏向,但必须先看到模拟结果,调整必须有原因、审计和过期时间。
|
||||
|
||||
个人空间只展示“我的今日容量”:我今天有效容量多少、已计划人工触点多少、为什么还能/不能承接、AI 预处理可释放多少人工时间。Team Layer 展示“团队今日容量”:团队需求结构、成员容量卡、瓶颈、AI 预处理建议、负责人调整入口和调整记录。`/workloads` 只保留为高级容量明细,传统工时和旧负载比例仅作为历史成本参考。
|
||||
|
||||
后续团队空间只能扩展自适应容量模型,不能回退为传统工时表、固定工作类型槽或简单 workload ratio 分配。
|
||||
|
||||
## 7. 底层对象模型
|
||||
|
||||
首版必须先定对象层,否则 UI 会漂。
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@
|
|||
5. `ExecutionBridge` 页面还没有消费 dev-sync / GitHub / PromptPackage 真实接口。
|
||||
6. `ReleaseGrowth` 已接入 execution evidence gate、release validation run 与 feedback ingest;revenue、ROI、compliance 还没有串成完整发布后反馈循环。
|
||||
7. `TeamLayer` 已接入 reviewer routing / review decision 读模型与 Team Feed 只读动态流,能展示待审 ChangeSet、建议审查人、审查原因、风险类型、缺角色 warning、审查状态、审查意见,以及 review / execution / release gate 聚合动态;Role Inbox、Decision Desk 仍缺完整真实数据模型。
|
||||
8. `Execution Capability Graph` 已确认为团队空间前置模型:旧 `用户管理 / 能力管理 / 团队负载` 不再作为一级产品功能,降级为高级设置/明细路由;个人空间展示执行准备度,TeamLayer 展示执行能力与容量信号;后续团队空间只能扩展 `AI + 工具 + 人类判断责任 + 项目上下文 + 容量信号`,不得回到传统用户/技能/工时报表三件套。
|
||||
9. `Adaptive Human Capacity` 已确认为旧工时负载的替代模型:每日 `suggestedBaseline` 必须由当天真实任务池动态生成,所有人工触点共享同一个容量池;总时间判断硬上限,类型占比只判断疲劳和质量风险。负责人 override 必须先 simulate,再 apply,并记录原因、审计、过期时间;团队空间后续不得回到固定工作类型槽、静态技能标签或旧 workload ratio 直接派活。
|
||||
|
||||
## 2. 下一步推进方向
|
||||
|
||||
|
|
@ -294,6 +296,7 @@
|
|||
范围:
|
||||
|
||||
- 成员归属。
|
||||
- 执行能力图谱:先判断 AI 直接执行、AI+用户验收、AI+技术审查、需人类补判断或当前阻塞。
|
||||
- 角色收件箱 Role Inbox。
|
||||
- Team Feed。
|
||||
- Decision Desk 或高风险变更多人审查。
|
||||
|
|
@ -825,6 +828,65 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh
|
|||
|
||||
## 8. 进度台账
|
||||
|
||||
### 2026-04-27 / integration-current-worktree-cleanup / done
|
||||
|
||||
完成内容:
|
||||
|
||||
- 收口当前 `codex/team-feed-p1-review-activity-stream` 脏工作区,确认本批次改动属于同一轮 Shell / Integration 收口:Team Feed 只读动态流、Adaptive Human Capacity、AI Routing / Providers 产品化控制台、Legacy AI Steward 下线、旧 Inbox 独立页删除,以及对应文档台账同步。
|
||||
- 共享入口已统一:`frontend/src/router/index.tsx` 只保留 `/ai-steward` legacy redirect、旧项目路由 redirect、`/space` 与项目主链身份边界、AI 运营中心 manager boundary;`backend/app/api/router.py` 将 `legacy_ai_steward_api / ai_routing / model_routing` 放入核心路由,确保 lean profile 下治理入口不 404。
|
||||
- migration 链路已检查:`20260427_add_human_capacity_overrides` 单 head,父级为 `20260426_add_pmflow_change_review_tables`,未形成多 head。
|
||||
- Inbox 旧独立页已退场:`/inbox` route、侧边栏入口、旧 `frontend/src/pages/inbox/`、`frontend/src/components/inbox/`、`frontend/src/types/inbox.ts` 与三阶段清单文档删除;旧 AI PMO `waiting_manual / draft_pending_confirm / missing_project_scope` 由 Space legacy adapter 承接。
|
||||
- 未继续新增业务功能;本轮仅做集成一致性确认、文档台账补齐、验证命令收口和小范围测试稳定性修复。
|
||||
|
||||
验证:
|
||||
|
||||
- 越界检查:`git diff --name-only` / `git status --short` 已检查,当前 diff 是同一 integration 批次,不再拆分;共享文件变更集中在 router、后端 API router、models init、docs 与测试配置。
|
||||
- 后端编译:`find backend/app -name '*.py' -print0 | xargs -0 /opt/homebrew/bin/python3.11 -m py_compile` 通过。
|
||||
- 后端测试:`PYTHONPATH=. /opt/homebrew/bin/python3.11 -m pytest tests/test_pmflow_os_projection_api.py tests/test_ai_routing_governance_service.py -q` 在 `backend/` 下通过,`36 passed`,仅剩既有 Pydantic V2 class-based config deprecation warning。
|
||||
- Alembic:`PYTHONPATH=. /opt/homebrew/bin/python3.11 -m alembic heads` 在 `backend/` 下返回 `20260427_add_human_capacity_overrides (head)`。
|
||||
- 前端:`npm run typecheck` 通过。
|
||||
- 前端:`npm run build` 通过。
|
||||
- Diff hygiene:`git diff --check` 通过;未发现 Git 冲突标记。
|
||||
|
||||
### 2026-04-27 / adaptive-human-capacity-p1 / implemented
|
||||
|
||||
完成内容:
|
||||
|
||||
- 将旧“工时负载”升级为 `Adaptive Human Capacity`:后端新增只读每日容量计划、simulate-before-apply、override 审计记录,计划由当前 truth-state / execution-summary / reviewer routing / workspace binding 实时聚合。
|
||||
- 新增 `human_capacity_overrides` 持久化表,只存负责人手工调整与审计,不存每日计划快照;过期 override 不参与今日计划。
|
||||
- `execution-capabilities` 补充 `adaptiveCapacitySummary`;个人空间展示“我的今日容量”;Team Layer 增加团队今日容量调度台、模拟结果、负责人调整入口和调整记录;`/workloads` 降级为高级容量明细并把传统工时标为成本参考。
|
||||
- 产品边界写入蓝图和 guardrails:每日建议基线不得固定,工作类型共享容量池,负责人 override 必须有原因、审计、过期时间,后续团队空间不得回到固定工时槽、静态技能标签或旧 workload ratio 派活。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `backend/app/models/human_capacity_override.py`
|
||||
- `backend/alembic/versions/20260427_add_human_capacity_overrides.py`
|
||||
- `backend/app/services/adaptive_capacity_service.py`
|
||||
- `backend/app/services/team_execution_capability_service.py`
|
||||
- `backend/app/api/pmflow_os_api/__init__.py`
|
||||
- `backend/app/schemas/pmflow_os.py`
|
||||
- `backend/tests/test_pmflow_os_projection_api.py`
|
||||
- `frontend/src/api/pmflowOs.ts`
|
||||
- `frontend/src/api/space.ts`
|
||||
- `frontend/src/features/pmflow-os/types.ts`
|
||||
- `frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx`
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.tsx`
|
||||
- `frontend/src/pages/workloads/WorkloadsPage.tsx`
|
||||
- `docs/PMFLOW_PRODUCT_BLUEPRINT.md`
|
||||
- `docs/architecture/API_CONTRACTS.md`
|
||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- `CLAUDE.md`
|
||||
|
||||
验证:
|
||||
|
||||
- 后端:`python -m py_compile backend/app` 通过;`pytest tests/test_pmflow_os_projection_api.py -q` 通过。
|
||||
- 数据库:`alembic upgrade head` 成功升级到 `20260427_add_human_capacity_overrides`。
|
||||
- 前端:`npm run typecheck`、`npm run build`、`vitest run src/features/personal-space/PersonalSpacePage.test.tsx src/features/pmflow-os/store.api.test.tsx src/components/layout/Sidebar.test.tsx` 通过。
|
||||
- 浏览器:检查 `/space`、`/team-layer?projectId=1`、`/workloads?projectId=1`,容量区块/空态/不可用 warning 可见,未使用 mock 或旧负载比例冒充。
|
||||
- 运行态:`docker compose restart backend` 后 `pmflow-backend` health 为 `healthy`。
|
||||
|
||||
### 2026-04-26 / team-feed-p1-review-activity-stream / implemented
|
||||
|
||||
完成内容:
|
||||
|
|
@ -2466,3 +2528,44 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh
|
|||
下一步:
|
||||
|
||||
- 从 A1 对象合同映射开始,把前端 `pmflow-os/types.ts` 与后端现有对象逐一对齐。
|
||||
|
||||
### 2026-04-27 / Legacy AI Steward Launch Downline / done
|
||||
|
||||
完成内容:
|
||||
|
||||
- 下线用户可见的 `/ai-steward` 产品页与侧边栏入口;legacy route 只保留迁移重定向,`tab=create` 进入 `/project-launch`,`tab=engine / tab=feedback-center` 进入 `/ai-config`,其它 legacy tab 进入 `/space`。
|
||||
- 收敛新建项目入口:前端移除 `aiStewardApi` 的直接创建调用与相关类型,后端 `POST /ai-steward/project-from-text` 返回 410 包络错误;真实项目创建唯一走 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`。
|
||||
- 保留并迁移仍有价值的能力:QA 浮窗、状态条、回答元卡片继续保留在 `frontend/src/components/ai-steward/`;QA URL 新生成到 `/space?view=ai-history`;反馈中心入口迁到 `/ai-config`;Context Cache Feature Catalog 与信号通知默认目标改到 `/space`。
|
||||
- 历史页面文件未做物理删除,只作为迁移占位或断链壳保留;如需后续清理文件,需要按删除确认流程列路径后单独执行。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `frontend/src/router/index.tsx`
|
||||
- `frontend/src/components/layout/Sidebar.tsx`
|
||||
- `frontend/src/components/layout/TopBar.tsx`
|
||||
- `frontend/src/components/ai-steward/qaPageAnchor.ts`
|
||||
- `frontend/src/components/sstc/FeedbackSummaryCard.tsx`
|
||||
- `frontend/src/api/aiSteward.ts`
|
||||
- `frontend/src/pages/ai-steward/AIStewardPage.tsx`
|
||||
- `frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`
|
||||
- `backend/app/api/ai_steward_api/routes_capture.py`
|
||||
- `backend/app/api/ai_steward_api/schemas.py`
|
||||
- `backend/app/api/legacy_ai_steward_api.py`
|
||||
- `backend/app/api/router.py`
|
||||
- `backend/app/services/context_cache_service/mixin_profile_basics.py`
|
||||
- `backend/app/services/context_cache_service/mixin_profile_route_parser.py`
|
||||
- `backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py`
|
||||
- `CLAUDE.md`
|
||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
|
||||
|
||||
验证:
|
||||
|
||||
- 残留检索:计划指定的 legacy create 入口、direct create API 名称、旧项目文本创建类型名与旧中文入口名均无命中。
|
||||
- 后端:`/opt/homebrew/bin/python3.11 -m compileall -q app` 通过。
|
||||
- 后端:`/opt/homebrew/bin/python3.11 -m pytest tests/test_ai_steward_project_from_text_contract.py tests/test_context_cache_profile_catalog.py tests/test_pmflow_launch_api.py -q` 通过,27 passed。
|
||||
- 前端:`npx vitest run src/router/legacyAIStewardRedirect.test.ts src/components/layout/Sidebar.test.tsx src/components/ai-steward/qaPageAnchor.test.ts` 通过,9 passed。
|
||||
- 前端:`npm run typecheck` 通过。
|
||||
- 前端:`npm run build` 通过。
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ PMFlow 专属规则:
|
|||
|
||||
### 4.3 项目层
|
||||
|
||||
用于 `项目指挥台 / 本轮结构化提取 / 决策与待确认 / 项目工作台 / 开发执行 / 发布与增长 / 团队协作 / 项目记忆`。
|
||||
用于 `项目指挥台 / 本轮结构化提取 / 决策与待确认 / 项目工作台 / 开发执行 / 发布与增长 / 团队层 / 项目记忆`。
|
||||
|
||||
结构:
|
||||
|
||||
|
|
@ -215,7 +215,7 @@ PMFlow 专属规则:
|
|||
决策与待确认
|
||||
开发执行
|
||||
发布与增长
|
||||
团队协作
|
||||
团队层
|
||||
项目记忆
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -48,23 +48,106 @@
|
|||
## Model Routing
|
||||
|
||||
### GET `/model-routing/functions/{function_name}`
|
||||
- Purpose: query effective routing for function
|
||||
- Purpose: query function-level advanced routing override. This is an admin/advanced entry, not the default product UI for members or team leads.
|
||||
- Response data:
|
||||
- `function_name`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `api_env`
|
||||
- `tier`
|
||||
- `source: "default" | "override"`
|
||||
|
||||
### PUT `/model-routing/functions/{function_name}`
|
||||
- Auth: manager/admin only
|
||||
- Request body:
|
||||
- `provider`, `model`, `api_env`, `tier`
|
||||
- `tier`
|
||||
- Side effects:
|
||||
- persist override into `integrations(type='model_routing')`
|
||||
- append audit to `operation_logs`
|
||||
|
||||
## AI Routing & Providers
|
||||
- UI placement: these endpoints power the team-level AI 运营中心 under `/ai-config`; they are not project-scoped and must not be presented as per-project provider/model settings.
|
||||
|
||||
### GET `/ai-routing/overview`
|
||||
- Purpose: productized AI routing overview for team leads and AI operators.
|
||||
- Auth: `OWNER` or `REVIEWER` via `can_view_ai_policy`.
|
||||
- Response data:
|
||||
- `control_plane`: `{ version, source, updated_at, baseline_note }`
|
||||
- `slots[]`: low/high slot summaries with Chinese labels, provider/model display summary, connection state, and masked key status.
|
||||
- `metrics`: recent 24h/7d calls, failures, failure rate, escalation rate, structured failure rate, average latency, and cost estimate.
|
||||
- `diagnostics[]`: risk cards such as env key override, high slot unavailable, same runtime for low/high, protected downgrade, structured failure spike, or local Ollama scope risk.
|
||||
- Boundary: no cleartext key, `api_env`, prompt, raw provider secret, raw error, or raw routing meta.
|
||||
|
||||
### GET `/ai-routing/team-policy`
|
||||
- Purpose: team-lead-facing business policy view for project launch, requirement structuring, task breakdown, assignee matching, internal QA, external query, Feishu understanding, and Feishu reply.
|
||||
- Auth: `OWNER` or `REVIEWER`.
|
||||
- Response data:
|
||||
- `strategy`: `quality_first | balanced | speed_first`
|
||||
- `scenes[]`: Chinese business scene policies, preferred mode, escalation rule, fallback behavior, and editable business note.
|
||||
- `automation_boundary`, `budget_guard`, `quality_targets`, `updated_at`, `updated_by`
|
||||
- Boundary: team policy is saved product governance state only; it does not rewrite `llm_service` runtime routing in v1.
|
||||
|
||||
### PUT `/ai-routing/team-policy`
|
||||
- Purpose: save team AI policy display/governance state.
|
||||
- Auth: `OWNER` only via `can_approve_ai_routing_change`.
|
||||
- Request body:
|
||||
- `strategy?: "quality_first" | "balanced" | "speed_first"`
|
||||
- `scenes?: [{ key, policy, mode?, human_confirmation?, protected? }]`
|
||||
- `automation_boundary?: string`
|
||||
- `budget_guard?: { enabled?: boolean, monthly_budget_cny?: number | null, alert_threshold_ratio?: number }`
|
||||
- `quality_targets?: string[]`
|
||||
- Side effects:
|
||||
- persist into `integrations(type='ai_team_policy')`
|
||||
- append `operation_logs(entity_type='ai_routing_policy')`
|
||||
- Boundary: never writes `backend/.control_plane/pmflow-routing-rules.json`, provider secrets, or model-routing overrides.
|
||||
|
||||
### GET `/ai-routing/admin-console`
|
||||
- Purpose: AI admin/operator console aggregating provider connection summaries, low/high slots, business-scene routes, recent failure samples, protected scenes, and env override diagnostics.
|
||||
- Auth: `OWNER` or `REVIEWER`.
|
||||
- Response data:
|
||||
- `providers[]`: display-only provider health and masked key state.
|
||||
- `slots[]`: fast/deep slot summaries.
|
||||
- `scene_routes[]`: scene label, mode, contract, protection state; raw `function_name` may only be present in admin data and must be hidden by default in UI advanced details.
|
||||
- `recent_failures[]`: sanitized failure samples without raw error or prompt.
|
||||
- `protected_scenes[]`, `environment_overrides[]`, `diagnostics[]`
|
||||
|
||||
### POST `/ai-routing/diagnostics/run`
|
||||
- Purpose: run read-only diagnostics for routing/provider health.
|
||||
- Auth: `OWNER` or `REVIEWER` via `can_operate_ai_routing`.
|
||||
- Side effects: none; does not modify provider, slot, team policy, or runtime routing.
|
||||
|
||||
## PMFlow OS Adaptive Capacity
|
||||
|
||||
All endpoints return `{ code, message, data }`. Project members may read. Override simulate/apply requires `OWNER` or `REVIEWER`; write endpoints only persist capacity override audit records and must not assign tasks, request review, write ChangeSet, or mutate Project Memory.
|
||||
|
||||
### GET `/pmflow-os/projects/{project_id}/capacity-plan?date=YYYY-MM-DD`
|
||||
- Purpose: compute the daily adaptive human capacity plan from current truth-state, reviewer routing, execution summary, workspace bindings, user projects, legacy workload reference, and active overrides.
|
||||
- Response data:
|
||||
- `projectId`, `date`, `generatedAt`
|
||||
- `teamDemandMix`
|
||||
- `teamEffectiveMinutes`, `teamPlannedTouchMinutes`
|
||||
- `memberPlans[]`: base/effective minutes, dynamic `suggestedBaseline`, current mix, planned touch minutes, status, remaining minutes, bottleneck, fatigue risk, risk density, `canTakeMore`, manual override, explanations
|
||||
- `humanTouches[]`: source, target href, work type, estimated minutes, risk, urgency, binding, assigned user, context readiness, AI reduction potential
|
||||
- `warnings`, `recommendations`
|
||||
- Boundary: daily plan is computed at read time. Do not persist fixed baselines or daily snapshots.
|
||||
|
||||
### POST `/pmflow-os/projects/{project_id}/capacity-overrides/simulate`
|
||||
- Purpose: simulate a manager capacity override before applying it.
|
||||
- Request body: `PMFlowCapacityOverridePayload`
|
||||
- Response data: `{ beforePlan, afterPlan, impactSummary, warnings }`
|
||||
- Boundary: no database write and no assignment side effect.
|
||||
|
||||
### POST `/pmflow-os/projects/{project_id}/capacity-overrides`
|
||||
- Purpose: apply a manager override and persist audit in `human_capacity_overrides`.
|
||||
- Request body: `PMFlowCapacityOverridePayload`
|
||||
- Response data: `PMFlowCapacityOverrideRecord`
|
||||
- Side effects: insert one override audit row only.
|
||||
- Boundary: `reason` is required; default expiry is same day 23:59 if omitted. Overtime increases time budget but must not clear fatigue or quality risk.
|
||||
|
||||
### GET `/pmflow-os/projects/{project_id}/capacity-overrides?date=YYYY-MM-DD`
|
||||
- Purpose: list override audit records for the given day.
|
||||
- Response data: `PMFlowCapacityOverrideRecord[]`
|
||||
|
||||
### GET `/pmflow-os/projects/{project_id}/execution-capabilities`
|
||||
- Addition: response data may include `adaptiveCapacitySummary: DailyCapacityPlan`.
|
||||
|
||||
## Task Briefs
|
||||
|
||||
### GET `/task-briefs/center`
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
- `docs/PMFLOW_PRODUCT_BLUEPRINT.md`:PMFlow 产品蓝图真源;锁定“对话驱动的 AI 产品执行操作系统”、`solo-first, team-ready`、项目真相层、ChangeSet 待确认、Execution Bridge 与人机混编团队方向
|
||||
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`:PMFlow 产品推进执行标准;记录当前进度、Phase A-F/P8 推进顺序、单次任务范围、Codex 并行推进协议、进度台账和新线程接续规则;P8 已完成 Review Decision Loop、Space review attention queue、Account review decision preference 与 integration 收口
|
||||
- `docs/PMFLOW_UI_CONTRACT.md`:PMFlow UI 契约真源;锁定 Logo、浅色白底 + `#2463EB`、移动端固定底部导航、四类视觉语法、核心功能 canonical 契约和当前前端一致性审计
|
||||
- `docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`:消息台内联操作执行清单
|
||||
- `docs/architecture/MASTER_CONTRACT.md`:跨栈主契约
|
||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`:新增代码参考入口与高频代码索引
|
||||
- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`:PMFlow OS 产品合同真源;锁定前端 `ProjectTruthState / ChangeSet / Evidence / Approval / ExecutionContext` 与后端 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的复用边界;Phase C1 已实现 review 表和审查 API,Phase C2 backend/frontend 已实现 atomic commit
|
||||
|
|
@ -148,10 +147,9 @@
|
|||
## 5. 前端库存
|
||||
|
||||
### 5.1 页面分组
|
||||
- `frontend/src/pages/inbox/`
|
||||
- `frontend/src/pages/ai-steward/`
|
||||
- `frontend/src/pages/ai-steward/`(legacy redirect / 迁移占位,不再作为产品页入口)
|
||||
- `frontend/src/pages/scheduler-center/`
|
||||
- `frontend/src/pages/project-board/`
|
||||
- `frontend/src/pages/project-board/`(legacy 项目控制面,用户入口已下线;旧 `/projects/:id/board` 由 router 重定向到 PMFlow OS 主链,待确认后物理删除)
|
||||
- `frontend/src/pages/projects/`
|
||||
- `frontend/src/pages/users/`
|
||||
- `frontend/src/pages/recipes/`
|
||||
|
|
@ -193,26 +191,29 @@
|
|||
- `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx`
|
||||
- AI Steward 问答浮窗现分三层显性化:`FloatingQAChat.tsx` 负责即时回答/历史回放归一化,`QAConversationStatusBar.tsx` 展示对象/来源/仓库/执行通道/plan handoff,`QAResponseMetaCard.tsx` 展示 `fit_brief / external_recommendation / recommended_next_step`,对应前端回归由 `FloatingQAChat.test.tsx` 与 `QAResponseMetaCard.test.tsx` 锁定。
|
||||
- 登录页与营销页仍共享 `frontend/src/components/marketing/` 的部分视觉基线;公开登录页现改由 `frontend/src/components/login/` 目录承载,其中 `LoginExperience.tsx` 负责登录壳层、`LoginWorkflowVisual.tsx` 负责左侧场景、`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何、`login.css` 负责登录页专属样式边界,`frontend/src/pages/login/LoginPage.tsx` 继续负责真实鉴权、错误态和 redirect 提示;未登录访问 `/` 会经 `frontend/src/router/index.tsx` 直接重定向到 `/login`,无显式 `redirect` 的成功登录现在默认进入 `/command-center`。
|
||||
- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过 `/space` 提供可直接打开的本地预览入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过 `/project-launch` 提供可直接打开的入口;Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=<id>`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。
|
||||
- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy + 统一身份边界”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`,统一身份/权限边界在 `frontend/src/auth/RouteBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,真实 `/project-launch` 是启动层入口,`/pmflow-os` 与 `/change-review / /structured-extraction / /memory-writeback-result / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,管理/治理类页面统一走 OWNER/REVIEWER 边界,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`/ai-steward` 只做 legacy redirect:`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;导航不再暴露该入口。`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、主链 standalone 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口。`frontend/src/features/personal-space/` 作为登录后的空间层入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 真实模式默认调用 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=<id>`,401/无 token 必须保存 pending input 并提示重新登录,不展示 Axios 原始错误;只有显式 demo 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为登录后的 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为登录后的真实项目产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接。
|
||||
- PMFlow P8 Review Decision Loop 已由 `big-module-integration-p8` 收口:Main Flow `truth-state.reviewerRouting` 与 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 是 reviewer decision / approval audit 真源;Space 只读消费为 `/space` 的 `review_attention` 下一步队列;Account 只提供 `getReviewDecisionNotificationPreference` 页面内提醒偏好;P8 未新增 route/nav,未接真实通知中心,不自动 assign reviewer 或 commit ChangeSet。
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state` 与 `execution-summary` 生成 `nextActionQueue` 我的下一步队列、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=<id>`,下一轮 draft 关注项进入 `/change-review?projectId=<id>&changeSetId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。
|
||||
- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过 `/space` 提供可直接打开的本地预览入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过 `/project-launch` 提供可直接打开的入口;Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=<id>`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`ChangeReviewPage.tsx` 现在可以请求审查、通过、要求修改或拒绝当前 ChangeSet,并展示审查状态、审查意见与 high-risk approval gate;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、warning、审查状态、审查意见和请求/决策时间,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作跳 `/change-review?projectId=<id>&changeSetId=<id>`;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state` 与 `execution-summary` 生成 `nextActionQueue` 我的下一步队列、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,把高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning 与普通 pending review 排入“我的下一步”,待审/退回/拒绝项只跳 `/change-review?projectId=<id>&changeSetId=<id>`,reviewer 缺失 warning 只跳已有 `/team-layer?projectId=<id>`。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。reviewerRouting、发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=<id>`,下一轮 draft 关注项进入 `/change-review?projectId=<id>&changeSetId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。
|
||||
- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过 `/space` 提供可直接打开的本地预览入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过 `/project-launch` 提供可直接打开的入口;Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=<id>`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数和 warning,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作只跳 `/change-review?projectId=<id>&changeSetId=<id>`,不自动 assign reviewer;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`;缺少能力快照、工具连接、执行证据或阻塞数据时显示明确 empty/warning,不用传统技能表或 mock 数据冒充真实能力。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=<id>`,下一轮 draft 关注项进入 `/change-review?projectId=<id>&changeSetId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。
|
||||
- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=<id>`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`ChangeReviewPage.tsx` 现在可以请求审查、通过、要求修改或拒绝当前 ChangeSet,并展示审查状态、审查意见与 high-risk approval gate;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、warning、审查状态、审查意见和请求/决策时间,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作跳 `/change-review?projectId=<id>&changeSetId=<id>`;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,把高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning 与普通 pending review 排入“我的下一步”,待审/退回/拒绝项只跳 `/change-review?projectId=<id>&changeSetId=<id>`,reviewer 缺失 warning 只跳已有 `/team-layer?projectId=<id>`。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。reviewerRouting、execution-capabilities、发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力、验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=<id>`,下一轮 draft 关注项进入 `/change-review?projectId=<id>&changeSetId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。
|
||||
- 旧独立收件页已物理删除,`/inbox` route 和侧边栏入口已退场;旧 AI PMO `waiting_manual / draft_pending_confirm` 不再通过独立页面承载,而由 `frontend/src/api/space.ts` 作为 legacy adapter 只读接入 `/space` 的“我的下一步”和草案箱。`missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口;底层 `notifications` primitive 暂留为未来 in-app reminder/偏好消费基础,不作为真实通知中心暴露。
|
||||
- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=<id>`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数和 warning,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作只跳 `/change-review?projectId=<id>&changeSetId=<id>`,不自动 assign reviewer;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。
|
||||
- `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/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、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调用 `/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` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可展示 recommendation quality grouping,把真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;`ChangeReviewPage.tsx` 会展示 `iteration_recommendation_group` evidence 的来源组、feedback 数、recommendation 数、风险、影响和审查优先级;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 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` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review,Iteration Loop P2 已接入 `POST /iteration-recommendation-groups/{group_key}/change-set-drafts` 并在 `releaseSignals.recommendationGroups` 暴露分组/去重/风险/影响合同;当前仍不调用真实 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/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 和失败提醒显示/静默状态;P3 新增 `getReleaseGateNotificationPreference(settings, event)`,把 Release/Growth 的 `release_gate_* / release_validation_*` 事件映射到 `releaseGate` 偏好,把 `failed_event` 映射到 `failureAlert` 偏好;P5/P6 的 `getPostReleaseNotificationPreference(settings, event)` 把发布后 `feedback_received / revenue_signal / compliance_blocked` 映射到 `releaseGate` 偏好,把 `iteration_recommendation / next_change_review` 映射到 `pendingConfirmation` 偏好和 `change_review` surface,并返回 `spaceReadable / evidencePolicy / reminderPolicy` 供 Main Flow 与 Space 消费;P7 将通知偏好扩展为 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并新增 `getAccountReminderConsumption(settings, event)` 覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,输出 `notificationTransport=in_app_only / evidencePolicy=preserve_business_evidence / auditPolicy=preserve_audit_state`;P8 新增 `getReviewDecisionNotificationPreference(settings, event)` 覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`,继续使用 `pendingConfirmation` 偏好和 `change_review` surface,并声明 reviewerRouting、approval、evidence、audit state 均必须保留。该合同只控制页面内提醒表达,不代表邮件、桌面推送或系统通知中心已接入;偏好关闭也不得隐藏业务证据、reviewerRouting、approval、recommendation、ChangeSet、执行状态或审计状态。`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、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest 与 iteration recommendation -> ChangeSet draft 均调用 `/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` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可把真实 recommendation 生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 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` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review;当前仍不调用真实 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 / review decision 服务位于 `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` 对应真实表。`ProjectTruthState.reviewerRouting` 是 Team layer 的 reviewer routing + decision 读模型:从 open/pending ChangeSet、待审 item 风险、跨对象类型、`iteration_recommendation` evidence、项目成员与 `OWNER / REVIEWER` 角色推导 `{status, warningCode, summary, items}`;item 内含 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl / reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;缺成员、缺角色或缺证据时 fail-closed 给 warning,不虚构 reviewer。P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,`approve / request_changes / reject` 写 `PMFlowApproval` 审计,high-risk approve 后才满足 C2 approval gate,所有 decision 都不自动 commit。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` 返回最新记录;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `FeedbackIngest / IterationRecommendation`,在 `ExecutionSummaryProjection.releaseSignals` 中返回真实发布后 feedback、revenue event 和 iteration recommendation 读模型;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 recommendation 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 `iteration_recommendation` evidence,重复调用复用既有 draft 并在 `releaseSignals` 标记 `changeSetCreated/changeSetId`;这些步骤都不直接执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`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 OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Execution Capability Graph 只读聚合位于 `backend/app/services/team_execution_capability_service.py`,Change Review / commit / review decision 服务位于 `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` 对应真实表。`GET /pmflow-os/projects/{project_id}/execution-capabilities` 返回 `executionModes / personalReadiness / teamSignals / warnings`,复用用户、项目成员、技能绑定、负载、reviewer routing、execution summary 与 workspace binding 数据,只给建议、阻塞和解释,不自动分派任务、不自动请求审查、不写 ChangeSet;缺 repo、工具、测试命令、reviewer 或证据时返回 warning、`blocked` 或 `needs_human_judgement`,不虚构能力结论。`ProjectTruthState.reviewerRouting` 是 Team layer 的 reviewer routing + decision 读模型:从 open/pending ChangeSet、待审 item 风险、跨对象类型、`iteration_recommendation` evidence、项目成员与 `OWNER / REVIEWER` 角色推导 `{status, warningCode, summary, items}`;item 内含 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl / reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;缺成员、缺角色或缺证据时 fail-closed 给 warning,不虚构 reviewer。P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,`approve / request_changes / reject` 写 `PMFlowApproval` 审计,high-risk approve 后才满足 C2 approval gate,所有 decision 都不自动 commit。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` 返回最新记录;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `FeedbackIngest / IterationRecommendation`,在 `ExecutionSummaryProjection.releaseSignals` 中返回真实发布后 feedback、revenue event 和 iteration recommendation 读模型;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 recommendation 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 `iteration_recommendation` evidence,重复调用复用既有 draft 并在 `releaseSignals` 标记 `changeSetCreated/changeSetId`;这些步骤都不直接执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。
|
||||
- Adaptive Human Capacity 后端由 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py` 与 `backend/app/schemas/pmflow_os.py` 承载;API 位于 `backend/app/api/pmflow_os_api/`:`GET /pmflow-os/projects/{project_id}/capacity-plan` 实时计算每日容量计划,`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate` 只模拟,`POST /pmflow-os/projects/{project_id}/capacity-overrides` 只写 override 审计,`GET /pmflow-os/projects/{project_id}/capacity-overrides` 返回调整记录;`execution-capabilities` 同步返回 `adaptiveCapacitySummary`。该模型不持久化每日计划快照,不写固定基线,不自动分配任务、不请求审查、不写 ChangeSet;旧 `UserWorkload.workload_ratio` 仅保留为成本参考信号。
|
||||
- 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。旧 `POST /ai-steward/project-from-text` 仅由 `backend/app/api/legacy_ai_steward_api.py` 保留 410 响应一个版本周期,且该模块在 lean profile 下也会挂载;禁止绕过草案确认直接创建正式项目。
|
||||
- `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/vite.config.ts` 现补了固定 vendor chunk 分组(`framework / state-query / ui-radix / icons / charts / markdown`)与业务 chunk 分组(`ai-steward / sstc / governance`),目标是让登录页与基础导航不再背 AI Steward、SSTC、治理页和图表代码。
|
||||
- `frontend/vite.config.ts` 保留固定第三方依赖 chunk 分组(`framework / state-query / ui-radix / icons / charts / markdown`);业务源码不再强制切 `ai-steward / sstc / governance` chunk,避免业务模块循环依赖在生产预览中触发初始化顺序错误。登录页与基础导航的体积治理继续依赖 route/page-section lazy。
|
||||
- 旧登录残留 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已删除,`frontend/src/components/login/` 成为唯一登录实现真源。
|
||||
- 前端开发链路已按职责拆分:`docker-compose.dev.yml` 下的 Vite 热更新开发前端固定占用 `5174`,静态生产预览前端默认改为 `5175`;`frontend/scripts/dev-entrypoint.sh` 避免每次容器启动都执行 `npm ci`,`frontend/scripts/login-live-check.mjs` 与 `package.json` 中的 `verify:login-fast / verify:login-responsive / verify:login-full`、`capture:login:matrix` 作为登录页快回路验证入口,截图产物统一落到 `frontend/output/login/`。
|
||||
- `frontend/src/api/taskBriefs.ts`
|
||||
- `frontend/src/api/modelRouting.ts`
|
||||
- `frontend/src/api/aiRouting.ts`
|
||||
- `frontend/src/api/mcp.ts`
|
||||
- `frontend/src/api/knowledgeGraph.ts`
|
||||
- `frontend/src/api/semanticFacts.ts`
|
||||
|
|
@ -220,9 +221,9 @@
|
|||
- `frontend/src/api/mountedAi.ts`
|
||||
- `frontend/src/api/feedbackCenter.ts`
|
||||
- `frontend/src/api/sstc.ts`
|
||||
- `frontend/src/components/inbox/`
|
||||
- `frontend/src/components/sstc/`
|
||||
- `frontend/src/pages/scheduler-center/components/`
|
||||
- `frontend/src/pages/ai-routing/`
|
||||
- `frontend/src/pages/ai-steward/components/`
|
||||
- `frontend/src/lib/`
|
||||
|
||||
|
|
@ -232,8 +233,10 @@
|
|||
- `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。
|
||||
- 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/治理后台语义。个人空间首页、新项目启动台和 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 写回。
|
||||
- `/ai-config` 是团队级 AI 运营中心,技术路由继续保留但用户可见不再叫“管理后台”或泛化 AI Config;首页按策略 / 连接 / 治理 / 高级分组组织 `/ai-config/team-ai-policy`、`/ai-config/ai-routing-console`、`/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 等入口,并明确这些配置影响当前团队空间内所有项目。治理页默认只对 `OWNER/REVIEWER` 或既有管理角色开放。AI 路由与供应商控制台由 `backend/app/api/ai_routing.py` 与 `backend/app/services/ai_routing_governance_service.py` 提供聚合,负责人视图只展示业务场景策略,管理员视图展示供应商连接、快速/深度槽位、诊断、高级函数详情和审计提示;团队策略只写 `integrations(type='ai_team_policy')`,不改 `llm_service` runtime 真源或 `backend/.control_plane/pmflow-routing-rules.json`。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。
|
||||
- AI 路由与供应商用户可见层必须脱敏:成员视图不得出现 low/high、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;负责人只看“质量优先 / 成本均衡 / 速度优先”和项目启动、需求结构化、任务拆解、人员匹配、内部问答、外部查询、飞书理解、飞书回复等场景策略;管理员高级详情默认隐藏 `function_name`,只在明确展开时展示。
|
||||
- 左侧导航不再展示团队负载、能力管理和用户管理作为一级产品入口;旧 `/users`、`/skills`、`/workloads`、`/users/:id/skills`、`/workloads/:id` 路由保留为高级设置/明细能力,用户可见语义分别收敛为成员与权限、能力词库、容量明细。`/workloads` 只保留传统工时参考、今日容量计划和 override 记录,不再作为 AI 分配核心。团队相关主入口统一为 `/team-layer`(团队层),承载 reviewer routing、Team Feed、执行能力、自适应容量、负责人容量模拟/调整与团队空间预留;AI 路由、供应商和语义治理归入“AI 运营中心”,成员不显示 AI 运营中心入口。项目级页面只消费 AI 处理模式与可信状态,不提供供应商、模型或底层路由配置。
|
||||
- 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 / execution-capabilities` 的只读聚合并跳回主流程;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`
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@
|
|||
- 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`)
|
||||
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
||||
- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`)
|
||||
- AI 路由与供应商产品化控制台接口:参考 `backend/app/api/ai_routing.py`(`GET /ai-routing/overview`、`GET/PUT /ai-routing/team-policy`、`GET /ai-routing/admin-console`、`POST /ai-routing/diagnostics/run`);该接口由 `backend/app/api/router.py` 核心路由加载,`API_ROUTE_PROFILE=lean` 下也必须可用
|
||||
|
||||
### 2.2 AI Steward / Training / Mounted Runtime
|
||||
- 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py`
|
||||
- 模型路由配置接口:参考 `backend/app/api/model_routing.py`
|
||||
- 模型路由配置接口:参考 `backend/app/api/model_routing.py`;作为 AI 运营中心“高级路由详情”的兼容入口,`API_ROUTE_PROFILE=lean` 下也必须由核心路由加载
|
||||
- AI 路由治理聚合服务:参考 `backend/app/services/ai_routing_governance_service.py`;聚合 `integrations(type='llm_engine'|'model_routing'|'ai_team_policy')`、`ai_call_logs` 与 `backend/.control_plane/pmflow-routing-rules.json`,只返回中文展示字段与 masked key,不返回明文 key、`api_env`、prompt、raw error 或 raw routing meta
|
||||
- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`
|
||||
- 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`
|
||||
- Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py`
|
||||
|
|
@ -70,7 +72,7 @@
|
|||
- AI PMO 恢复接口:参考 `POST /ai-pmo/work-items/{work_item_id}/resume`
|
||||
- AI PMO 草稿决策接口:参考 `POST /ai-pmo/work-items/{work_item_id}/draft-decision`
|
||||
- AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision`
|
||||
- AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items`
|
||||
- AI PMO legacy 工作项列表接口:参考 `GET /ai-pmo/work-items`;当前只作为 Space legacy adapter 输入,独立收件页已退场
|
||||
- AI PMO `process-text/work-item` 的 `plan_mode / plan_status / requirement_plan_context_summary` 投影:参考 `backend/app/api/ai_pmo_api/helpers_inbox_payload.py` 与 `backend/app/api/ai_pmo_api/routes_core.py`
|
||||
- `/rag/ask` 与 `/ai-pmo/process-text` 注入 `project_operating_context`:参考 `backend/app/api/rag_ask_contract.py` 与 `backend/app/api/ai_pmo_api/routes_core.py`
|
||||
|
||||
|
|
@ -89,8 +91,10 @@
|
|||
- SSTC 负责人推荐接口:参考 `POST /projects/{project_id}/tasks/{task_id}/recommend-assignee`
|
||||
- SSTC 任务更新接口:参考 `PATCH /projects/{project_id}/tasks/{task_id}`
|
||||
- SSTC 发布创建接口:参考 `POST /projects/{project_id}/releases`
|
||||
- PMFlow OS 真实读路径、C1 Change Review API、C2 backend commit、Execution Handoff、Release Evidence Gate、Release Validation Run、Release Feedback Signal、Iteration Loop draft、Team reviewer decision loop 与 Team Feed 只读动态流:参考 `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`;只读提供 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,复用 `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`;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 读模型,会从 open/pending ChangeSet、待审 item 风险、跨对象类型、evidence source、项目成员与 `OWNER / REVIEWER` 角色生成 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl`,并返回 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason` 审查决策只读字段;`truth-state.feed` 是 Team Feed 只读动态流,从 review request / review decision / ChangeSet 状态 / `ProgressEvent` / release gate 聚合,不写审批、不写 ChangeSet、不写通知;P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,复用 `PMFlowApproval` 写审计,`approve` 满足 high-risk C2 approval gate 但不自动 commit,`request_changes / reject` 不写 Project Memory;C1 写 API 包括 thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit`,默认 `mode=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`,对已确认任务生成/复用 `PromptPackage` 并创建 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,记录运行端领取、更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并创建 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 已在 `execution-summary.releaseGate` 中从 branch / commit / PR / test_passed / checks_passed / failed / blocked 等 `ProgressEvent` 推导 `ready / blocked / needs_confirmation / empty`;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,从当前 `releaseGate` 固化 `ReleaseValidationRun` 快照并在 `releaseGate.latestValidationRun` 返回最新记录,无 evidence 时返回 `release_validation_evidence_empty`;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用 `FeedbackIngest / IterationRecommendation` 写入真实发布后反馈,并在 `execution-summary.releaseSignals` 投影真实 `feedback / revenueEvents / recommendations` 与 release gate / validation run 关系;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 `IterationRecommendation` 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 iteration evidence,重复调用复用既有 draft;Iteration Loop P2 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendation-groups/{group_key}/change-set-drafts`,并在 `execution-summary.releaseSignals.recommendations` 补 `groupKey / duplicateOf / mergedCount / impactLevel / riskLevel / suggestedChangeTypes / reviewPriority`,在 `recommendationGroups` 聚合同组建议,支持同组 recommendation 批量生成一个可复用 ChangeSet draft 与 `iteration_recommendation_group` evidence;这些步骤均不执行发布命令、外部支付、真实推送、自动 Project Memory commit 或正式 Task 创建
|
||||
- PMFlow OS 真实读路径、C1 Change Review API、C2 backend commit、Execution Handoff、Release Evidence Gate、Release Validation Run、Release Feedback Signal、Iteration Loop draft、Team reviewer decision loop、Execution Capability Graph 与 Team Feed 只读动态流:参考 `backend/app/api/pmflow_os_api/`、`backend/app/services/pmflow_os_projection_service.py`、`backend/app/services/pmflow_change_review_service.py`、`backend/app/services/team_execution_capability_service.py`、`backend/app/schemas/pmflow_os.py`;只读提供 `GET /pmflow-os/projects/{project_id}/truth-state`、`GET /pmflow-os/projects/{project_id}/execution-summary` 与 `GET /pmflow-os/projects/{project_id}/execution-capabilities`,复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / users / user_projects / user_workloads / user_skill_bindings`;`execution-capabilities` 只输出执行模式、个人执行准备度、成员执行能力与容量信号、warning,不自动分配任务、不自动请求审查、不写 ChangeSet;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 读模型,会从 open/pending ChangeSet、待审 item 风险、跨对象类型、evidence source、项目成员与 `OWNER / REVIEWER` 角色生成 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl`,并返回 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason` 审查决策只读字段;`truth-state.feed` 是 Team Feed 只读动态流,从 review request / review decision / ChangeSet 状态 / `ProgressEvent` / release gate 聚合,不写审批、不写 ChangeSet、不写通知;P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,复用 `PMFlowApproval` 写审计,`approve` 满足 high-risk C2 approval gate 但不自动 commit,`request_changes / reject` 不写 Project Memory;C1 写 API 包括 thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit`,默认 `mode=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`,对已确认任务生成/复用 `PromptPackage` 并创建 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,记录运行端领取、更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并创建 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 已在 `execution-summary.releaseGate` 中从 branch / commit / PR / test_passed / checks_passed / failed / blocked 等 `ProgressEvent` 推导 `ready / blocked / needs_confirmation / empty`;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,从当前 `releaseGate` 固化 `ReleaseValidationRun` 快照并在 `releaseGate.latestValidationRun` 返回最新记录,无 evidence 时返回 `release_validation_evidence_empty`;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用 `FeedbackIngest / IterationRecommendation` 写入真实发布后反馈,并在 `execution-summary.releaseSignals` 投影真实 `feedback / revenueEvents / recommendations` 与 release gate / validation run 关系;Iteration Loop P1/P2 支持 recommendation 单条或同组生成 ChangeSet draft;这些步骤均不执行发布命令、外部支付、真实推送、自动 Project Memory commit 或正式 Task 创建。
|
||||
- Adaptive Human Capacity:参考 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py`、`frontend/src/api/pmflowOs.ts`、`frontend/src/api/space.ts`、`frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx`、`frontend/src/features/personal-space/PersonalSpacePage.tsx` 与 `frontend/src/pages/workloads/WorkloadsPage.tsx`;接口为 `GET /pmflow-os/projects/{project_id}/capacity-plan`、`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate`、`POST /pmflow-os/projects/{project_id}/capacity-overrides`、`GET /pmflow-os/projects/{project_id}/capacity-overrides`,并在 `execution-capabilities.adaptiveCapacitySummary` 返回今日容量摘要。每日计划由当前任务池实时计算,不持久化固定基线;`human_capacity_overrides` 只存负责人手工调整、原因、过期时间和审计;写接口不自动派任务、不请求审查、不写 ChangeSet。
|
||||
- Project Launch 真实创建链:参考 `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` 与 `POST /pmflow-launch/drafts/{draft_id}/confirm`,draft 只写 `pmflow_launch_drafts`,confirm 在同一事务内创建 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`
|
||||
- Legacy project create 410 路由:参考 `backend/app/api/legacy_ai_steward_api.py`;该模块在 `CORE_ROUTE_MODULES` 中始终挂载,只保留 `POST /ai-steward/project-from-text` 的 410 包络响应,避免 lean profile 下未知外部调用落成 404。
|
||||
- PMFlow Change Review 模型与 migration:参考 `backend/app/models/pmflow_change_review.py` 与 `backend/alembic/versions/20260426_add_pmflow_change_review_tables.py`;只包含 `pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`,本阶段不得新增或依赖 `/commit`
|
||||
|
||||
## 3. 前端参考
|
||||
|
|
@ -98,48 +102,49 @@
|
|||
- PMFlow 大模块并行公共壳:参考 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `4.4.1-4.4.3` 与 `frontend/src/router/index.tsx`、`frontend/src/main.tsx`、`frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`、`frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`;`/space` 归 Space lane,`/pmflow-os / /command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route,新增或改动必须由 Shell / Integration owner 串行处理
|
||||
- PMFlow P8 Review Decision Loop 已集成收口:参考 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 第 7 节和 `big-module-integration-p8` 台账;Main Flow 以 `truth-state.reviewerRouting` 和 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 提供 reviewer decision / approval audit 合同,Space 只读消费为 `/space` 的 `review_attention` 下一步队列,Account 只提供 review decision / high-risk approval 页面内提醒偏好;P8 不新增战略页面、不改 router/nav、不接真实通知中心,继续使用既有 `/change-review / /team-layer / /space / /me` 表面
|
||||
- PMFlow OS 产品合同真源:先读 `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`,再实现 `pmflow-os` 真实后端接入;该文档锁定 `ProjectTruthState / Thread / ChangeSet / ChangeItem / Evidence / Approval / Project Memory / ExecutionContext` 与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的复用边界和后续 API 草案
|
||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`;Space 工作面只读聚合参考 `frontend/src/api/space.ts`,只消费项目摘要与 PMFlow OS `truth-state / execution-summary`,不写 Project Memory、ChangeSet 状态、ProgressEvent 或 ReleaseValidationRun;Space P6 的 `nextActionQueue` 在 Space 本地读模型内把高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后 feedback/revenue/compliance 信号统一排序为“我的下一步”,所有 item 只跳 `/change-review`、`/execution-bridge` 或 `/release-growth`;Space P3 的 `releaseAttention` 优先从 `truth-state.releases` 的 release validation 投影生成“发布验证阻塞 / 发布验证待确认”,没有发布验证投影时才回落到 `PMFlowExecutionSummary.releaseGate` 的“发布候选 / 发布门禁阻塞”,所有动作只跳转 `/release-growth?projectId=<id>`;PMFlow OS 真实接入参考 `frontend/src/api/pmflowOs.ts` 的 `getPMFlowTruthState(projectId)`、`getPMFlowExecutionSummary(projectId)`、`createPMFlowExecutionHandoff`、`consumePMFlowExecutionHandoff`、`createPMFlowExecutionProgressEvent`、`createPMFlowReleaseValidationRun`、`createPMFlowReleaseFeedbackIngest`、`createPMFlowIterationRecommendationChangeSetDraft`、`createPMFlowIterationRecommendationGroupChangeSetDraft`、`createPMFlowReviewDecision`、`createPMFlowThreadMessage`、`createPMFlowExtraction`、`getPMFlowChangeSet`、`acceptPMFlowChangeItem`、`editPMFlowChangeItem`、`deferPMFlowChangeItem`、`commitPMFlowChangeSet`;`PMFlowExecutionSummary.releaseGate` 是 ReleaseGrowth 真实发布门禁前端合同,`releaseGate.latestValidationRun` 是最新发布验证记录前端合同,`execution-summary.releaseSignals` 是 Release/Growth 发布后 feedback/revenue/recommendation 信号、recommendation quality grouping 与下一轮 ChangeSet draft 状态前端合同;Project Launch 创建链参考 `frontend/src/api/pmflowLaunch.ts` 的 `createPMFlowLaunchDraft` 与 `confirmPMFlowLaunchDraft`
|
||||
- AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/`
|
||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`;Space 工作面只读聚合参考 `frontend/src/api/space.ts`,只消费项目摘要与 PMFlow OS `truth-state / execution-summary / execution-capabilities`,不写 Project Memory、ChangeSet 状态、ProgressEvent 或 ReleaseValidationRun;Space P6 的 `nextActionQueue` 在 Space 本地读模型内把高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后 feedback/revenue/compliance 信号统一排序为“我的下一步”,所有 item 只跳 `/change-review`、`/execution-bridge` 或 `/release-growth`;Space 的 `executionReadiness` 只读展示工具连接、待确认、待审查、待验收和阻塞原因;PMFlow OS 真实接入参考 `frontend/src/api/pmflowOs.ts` 的 `getPMFlowTruthState(projectId)`、`getPMFlowExecutionSummary(projectId)`、`getPMFlowExecutionCapabilities(projectId)`、`createPMFlowExecutionHandoff`、`consumePMFlowExecutionHandoff`、`createPMFlowExecutionProgressEvent`、`createPMFlowReleaseValidationRun`、`createPMFlowReleaseFeedbackIngest`、`createPMFlowIterationRecommendationChangeSetDraft`、`createPMFlowIterationRecommendationGroupChangeSetDraft`、`createPMFlowReviewDecision`、`createPMFlowThreadMessage`、`createPMFlowExtraction`、`getPMFlowChangeSet`、`acceptPMFlowChangeItem`、`editPMFlowChangeItem`、`deferPMFlowChangeItem`、`commitPMFlowChangeSet`;`PMFlowExecutionSummary.releaseGate` 是 ReleaseGrowth 真实发布门禁前端合同,`execution-summary.releaseSignals` 是 Release/Growth 发布后信号与下一轮 ChangeSet draft 状态前端合同;Project Launch 创建链参考 `frontend/src/api/pmflowLaunch.ts`。
|
||||
- Legacy AI Steward route:`/ai-steward` 不再作为产品页;参考 `frontend/src/router/index.tsx` 的 `getLegacyAIStewardRedirectPath`,`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;`frontend/src/pages/ai-steward/` 仅保留迁移占位和已断链的历史 tab 壳,不作为新功能入口。
|
||||
- AI Steward 问答浮窗与上下文显性化组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
|
||||
- AI Steward QA contract 前端类型(含 `analysis_packet`):参考 `frontend/src/api/aiSteward.ts`
|
||||
- AI Steward handoff 元卡片回归:参考 `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx`
|
||||
- AI Steward / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts`
|
||||
- PMFlow OS 前端 MVP 产品域:参考 `frontend/src/features/pmflow-os/`
|
||||
- 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;本地预览路由入口为 `/space`,首屏以 `nextActionQueue` 展示“我的下一步”,并保留继续推进、待我确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱与最近项目明细;点击项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,review attention 待审项进入 `/change-review?projectId=<id>&changeSetId=<id>`,reviewer 缺失 warning 进入已有 `/team-layer?projectId=<id>`,执行中项进入 `/execution-bridge?projectId=<id>`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=<id>`,已生成 ChangeSet draft 的下一轮建议进入 `/change-review?projectId=<id>&changeSetId=<id>`;Space 只读聚合项目摘要、PMFlow OS `truth-state.reviewerRouting / truth-state.releases / execution-summary.releaseGate / execution-summary.releaseSignals`,reviewerRouting、发布验证、发布后信号或 iteration draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、验证、反馈、收入、ROI、合规、iteration recommendation 或 ChangeSet draft,不直接写 Project Memory、ChangeSet 状态、PMFlowApproval、execution event、ReleaseValidationRun、FeedbackIngest、RevenueEvent 或 ComplianceCheckRun;暂存想法后端只读聚合尚未接入时必须显示 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/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;登录后路由入口为 `/space`,未登录由 `frontend/src/auth/RouteBoundary.tsx` 跳转 `/login?redirect=/space`;首屏以 `nextActionQueue` 展示“我的下一步”,并保留执行准备度、继续推进、待我确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、缺项目归属补全与最近项目明细;点击项目主动作进入 `/pmflow-os?projectId=<id>`,待确认项进入 `/change-review?projectId=<id>&changeSetId=<id>`,review attention 待审项进入 `/change-review?projectId=<id>&changeSetId=<id>`,reviewer 缺失 warning 进入已有 `/team-layer?projectId=<id>`,执行中与执行准备度项进入 `/execution-bridge?projectId=<id>`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=<id>`;Space 只读聚合项目摘要、PMFlow OS `truth-state.reviewerRouting / truth-state.releases / execution-summary / execution-capabilities`,并兼容读取旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项;`missing_project_scope` 仅在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision`,不新增旁路接口,不直接写项目真相层。缺数据时显示明确空态或 warning,不虚构 reviewer、审查意见、待审 ChangeSet、执行能力、工具连接、验证、反馈、收入、ROI、合规、iteration recommendation 或 ChangeSet draft,不直接写 Project Memory、ChangeSet 状态、PMFlowApproval、execution event、ReleaseValidationRun、FeedbackIngest、RevenueEvent 或 ComplianceCheckRun;暂存想法后端只读聚合尚未接入时必须显示 empty/warning,不得用 mock 数据冒充真实内容;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该组件
|
||||
- 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式登录后路由入口为 `/project-launch`,默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 draft/confirm API;新建项目唯一写链路是 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`,旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误;只有显式 `?demo=1` 或 `?dataSource=demo` 才允许匿名并启用 `mockData.ts` 本地演示状态机;401/无 token 通过 `frontend/src/lib/apiError.ts` 归一为 `auth_required/session_expired`,通过 `frontend/src/lib/pendingIntent.ts` 保留输入,用户可见失败态不得展示 `Request failed with status code 401` 或把 401 归为网络异常
|
||||
- 前端身份边界与产品化错误:参考 `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`;`/space`、真实 `/project-launch`、`/me`、主链 standalone 与管理/治理入口均在前端边界收口,管理/治理入口使用 OWNER/REVIEWER manager 判断,后端 JWT/RBAC 仍是最终校验;pending intent 第一批只恢复 project launch 输入,不自动重放写操作
|
||||
- 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、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端并 refetch,不本地 promote 入 Project Memory、不伪造 approval、execution event、发布验证记录、发布后信号或下一轮 draft;`?demo=1` 才启用本地 mock 演示状态机和本地动作;项目内 AI 结构化闭环状态机为 `command_idle / ai_extracting / extraction_ready / review_open / writeback_success`
|
||||
- Account Settings / `/me` 前端产品域与 P1-P8 偏好合同:参考 `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`,把通知偏好映射为页面内提醒消费模型;P3 新增 `getReleaseGateNotificationPreference(settings, event)` 供 Release/Growth 后续消费:`release_gate_*` 与 `release_validation_*` 使用 `releaseGate` 偏好,`failed_event` 使用 `failureAlert` 偏好;P5/P6 的 `getPostReleaseNotificationPreference(settings, event)` 供发布后反馈与下一轮待确认消费:`feedback_received / revenue_signal / compliance_blocked` 使用 `releaseGate` 偏好,`iteration_recommendation / next_change_review` 使用 `pendingConfirmation` 偏好并落到 `change_review` surface;P7 将每类通知偏好扩展为 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并新增 `getAccountReminderConsumption(settings, event)` 覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,稳定返回 `notificationTransport=in_app_only / evidencePolicy=preserve_business_evidence / auditPolicy=preserve_audit_state`;P8 新增 `getReviewDecisionNotificationPreference(settings, event)` 覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`,统一使用 `pendingConfirmation` 偏好和 `change_review` surface,返回 `reviewerRoutingPolicy=preserve_reviewer_routing / approvalPolicy=preserve_approval_state`,偏好关闭只静默页面内提醒,不隐藏 reviewerRouting、approval、evidence 或 audit state;ReleaseGrowthPage 当前消费 `releaseGate` 与 `failureAlert` 两个偏好,只静默强调型提醒,不隐藏 release evidence、阻塞原因、审计状态、validation run、releaseSignals 或下一轮 ChangeSet draft;这些 helper 只控制本地 UI 提醒消费合同,不代表真实邮件、桌面推送或系统通知中心已接入
|
||||
- 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`;`ChangeReviewPage.tsx` 真实模式可对当前 ChangeSet 请求审查、通过、要求修改或拒绝,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage.tsx` 真实模式读取 `state.reviewerRouting` 展示 ready/empty/warning、待审 ChangeSet、来源(下一轮迭代 / AI 提取 / 手动)、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、审查状态、审查意见、请求/决策时间,并跳转 `/change-review?projectId=<id>&changeSetId=<id>`;`ReleaseGrowthPage.tsx` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,缺证据或缺发布后信号时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run、摄取 release feedback,从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,并解释 `iteration_recommendation_group` evidence 的组、反馈、风险、影响和审查优先级;生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不执行部署命令、不接外部支付;同时消费 Account notification preference helper,`releaseGate` 关闭时静默发布门禁强调提醒,`failureAlert` 关闭时静默失败强调提醒,所有真实证据和审计状态仍可见;静态高保真发布/增长数据仅保留在显式 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`;`ChangeReviewPage.tsx` 真实模式可对当前 ChangeSet 请求审查、通过、要求修改或拒绝,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage.tsx` 真实模式读取 `state.reviewerRouting` 与 `execution-capabilities`,展示待审 ChangeSet 审查路由、执行能力与容量信号、个人执行准备度、工具准备度和 Team Feed,只给建议与跳转,不自动分派、不自动审查、不写 ChangeSet;`ReleaseGrowthPage.tsx` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,缺证据或缺发布后信号时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run、摄取 release feedback,从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft;生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不执行部署命令、不接外部支付;所有真实证据和审计状态仍可见,静态高保真发布/增长数据仅保留在显式 demo 模式
|
||||
- Team Feed 只读动态流:`TeamLayerPage.tsx` 复用 `frontend/src/features/pmflow-os/components/TeamFeedPanel.tsx` 展示 `state.feed`,由后端 `ProjectTruthState.feed` 聚合 review request、review decision、ChangeSet 状态、execution progress 与 release gate 事件;前端只做空态、摘要和跳转展示,不写审批、不写 ChangeSet、不写通知,也不在真实模式 fallback 到 mock 动态。
|
||||
- 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`
|
||||
- Space 工作面回归测试:参考 `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`,覆盖空态、导航只读约束、发布/反馈/迭代草案队列,以及 reviewerRouting -> `review_attention` 的 next action 聚合排序与 `/change-review`、`/team-layer` 跳转合同。
|
||||
- Space 工作面回归测试:参考 `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`,覆盖空态、导航只读约束、执行准备度、发布/反馈/迭代草案队列,以及 reviewerRouting -> `review_attention` 的 next action 聚合排序与 `/change-review`、`/team-layer` 跳转合同。
|
||||
- Project Launch 真实/演示分支回归测试:参考 `frontend/src/features/project-launch/useProjectLaunchState.test.ts`
|
||||
- Account Settings 前端合同回归测试:参考 `frontend/src/features/account-settings/accountSettings.test.ts`,覆盖默认偏好、账号同步 fallback、通知偏好消费模型、提醒强度/证据展示合同、Release/Growth 通知消费合同、review decision / reviewer request / high-risk approval 偏好合同、失败事件映射和缺字段 fallback。
|
||||
- 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`
|
||||
- Inbox 页面入口:参考 `frontend/src/pages/inbox/`
|
||||
- Inbox `plan_mode` 展开卡片与映射:参考 `frontend/src/components/inbox/InboxListItem.tsx`、`frontend/src/lib/inbox.ts`、`frontend/src/api/aiPmo.ts`
|
||||
- 网页问答显式锚点与 Space AI 历史 URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`;新生成的 QA 锚点进入 `/space?view=ai-history`,函数仍能解析 legacy `/ai-steward` URL 以兼容历史链接
|
||||
- 登录页/营销页的共享视觉基线:参考 `frontend/src/components/marketing/MarketingFrame.tsx` 与 `frontend/src/components/marketing/MarketingVisuals.tsx`
|
||||
- 公开登录页入口:参考 `frontend/src/pages/login/LoginPage.tsx`、`frontend/src/components/login/LoginExperience.tsx`、`frontend/src/components/login/LoginWorkflowVisual.tsx`、`frontend/src/components/login/loginScene.ts`、`frontend/src/components/login/loginMath.ts` 与 `frontend/src/router/index.tsx`
|
||||
- 登录页分级验证:参考 `frontend/package.json` 中的 `verify:login-fast / verify:login-responsive / verify:login-full`、`capture:login:matrix`、`frontend/scripts/login-live-check.mjs` 与 `frontend/src/pages/login/LoginPage.test.tsx`
|
||||
- 顶层路由懒加载与统一 fallback:参考 `frontend/src/router/index.tsx` 与 `frontend/src/components/layout/LazyBoundary.tsx`
|
||||
- 顶层前端 Provider 入口:参考 `frontend/src/main.tsx`(`PMFlowOsProvider` + `RouterProvider`)
|
||||
- AI Steward tab 级懒加载入口:参考 `frontend/src/pages/ai-steward/AIStewardPage.tsx`
|
||||
- AI Config section 级懒加载入口:参考 `frontend/src/pages/ai-config/AIConfigPage.tsx`
|
||||
- 项目看板控制面/对话框懒加载入口:参考 `frontend/src/pages/project-board/ProjectBoardPage.tsx`
|
||||
- AI Steward legacy redirect 入口:参考 `frontend/src/router/index.tsx` 与 `frontend/src/pages/ai-steward/AIStewardPage.tsx`;不再新增 tab 级业务入口
|
||||
- AI 运营中心 section 级懒加载入口:参考 `frontend/src/pages/ai-config/AIConfigPage.tsx`;`/ai-config` 技术路由保留,首页按策略 / 连接 / 治理 / 高级分组组织团队级 AI 配置与治理入口
|
||||
- 项目索引与旧项目路由兼容:项目索引参考 `frontend/src/pages/projects/ProjectsPage.tsx`;旧 `/projects/:id/board` 只在 `frontend/src/router/index.tsx` 中重定向到 `/pmflow-os?projectId=:id`,旧 `/projects/:id/charter/:charterId` 重定向到 `/project-memory?projectId=:id`;`frontend/src/pages/project-board/` 仅保留为待物理删除的 legacy 代码,不再作为新产品实现参考。
|
||||
- 用户页对话框懒加载入口:参考 `frontend/src/pages/users/UsersPage.tsx`
|
||||
- 图表二级 chunk 入口:参考 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`
|
||||
- Vite chunk 分组与 watch 忽略策略:参考 `frontend/vite.config.ts`
|
||||
- Scheduler Center / Mounted AI Console:参考 `frontend/src/pages/scheduler-center/`
|
||||
- SSTC 工作流组件:参考 `frontend/src/components/sstc/`
|
||||
- Model Routing 页面:参考 `frontend/src/pages/model-routing-config/`
|
||||
- AI 路由与供应商控制台页面:参考 `frontend/src/pages/ai-routing/`;`TeamAIPolicyPage.tsx` 是负责人策略视图,`AIRoutingConsolePage.tsx` 是管理员诊断与槽位视图,薄入口位于 `frontend/src/pages/TeamAIPolicyPage.tsx`、`frontend/src/pages/AIRoutingConsolePage.tsx`
|
||||
- AI 路由与供应商前端 API:参考 `frontend/src/api/aiRouting.ts`
|
||||
- MCP 模板接入页:参考 `frontend/src/pages/ai-config/mcp-config/`
|
||||
- Route / Context 治理页:参考 `frontend/src/pages/query-route-governance/`
|
||||
- Semantic facts 治理页:参考 `frontend/src/pages/semantic-facts-board/`
|
||||
- MCP 前端 API:参考 `frontend/src/api/mcp.ts`
|
||||
- Route / Context 治理前端 API:参考 `frontend/src/api/queryRouteGovernance.ts`
|
||||
- Semantic facts 前端 API:参考 `frontend/src/api/semanticFacts.ts`
|
||||
- AI Config 治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx`
|
||||
- AI 运营中心治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx`;`GOVERNANCE_TOOL_GROUPS` 是首页分组真源,`GOVERNANCE_TOOLS` 保留给既有测试与入口兼容
|
||||
|
||||
## 4. 模型与配置真源
|
||||
- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
- 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。
|
||||
- 所有写接口必须落审计日志(`operation_logs`),至少包含 `trace_id/interface_path/caller`。
|
||||
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列。
|
||||
- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与管理/治理入口必须登录,管理/治理入口需 OWNER/REVIEWER;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。
|
||||
- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback)。401/无 token 必须表达为登录/会话问题;需要恢复输入时只能保存 pending intent 并由用户重新触发写操作,禁止登录后自动重放真实写请求。
|
||||
|
||||
### 3.2 核心业务零兜底
|
||||
- 模型失败、超时、空响应、结构化解析失败时,必须显式返回真实失败原因。
|
||||
|
|
@ -64,13 +66,17 @@
|
|||
- `fallback_policy`
|
||||
- `execution_mode`
|
||||
- 开发环境启动必须允许显式关闭重型副作用:`STARTUP_MODEL_PROBE_ENABLED`、`FEISHU_LONG_CONNECTION_ENABLED`、`STARTUP_WORLD_MODEL_WARMUP_ENABLED`、`STARTUP_SCHEDULERS_ENABLED`、`STARTUP_BACKGROUND_WORKERS_ENABLED` 是当前唯一允许的 startup 开关;需要压缩热重载时,应优先通过这些环境变量降载,而不是在代码里硬删启动逻辑或把副作用偷偷移到 import side effect。
|
||||
- `backend/app/api/router.py` 的 optional route group 只能通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 控制;默认 `full` 保持现有全量 API,`lean` 只允许作为开发减载配置使用,禁止在运行时代码里基于请求内容或关键词动态裁路由。
|
||||
- `backend/app/api/router.py` 的 optional route group 只能通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 控制;默认 `full` 保持现有全量 API,`lean` 只允许作为开发减载配置使用,禁止在运行时代码里基于请求内容或关键词动态裁路由。团队级 AI 运营中心依赖的 `ai_routing` API 与高级路由详情依赖的 `model_routing` API 属于核心路由,必须在 `lean` 下继续加载,避免 `/ai-config/*` 治理页面变成 404。
|
||||
- 开发预览端口职责必须固定:`5174` 仅保留给源码热更新前端,`docker-compose.yml` 下的静态生产预览前端默认改用 `5175`(`FRONTEND_PROD_PORT` 可覆盖);禁止再让 `5174` 在静态容器和 Vite dev server 之间来回漂移,避免浏览器验证和真实源码脱节。
|
||||
- 禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。
|
||||
- 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错。
|
||||
- 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏。
|
||||
- `integrations(type='model_routing')` 的 runtime tier override 不得把 `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` 这类用户可见 QA 主函数降级到 `low`。
|
||||
- 模型默认值与 provider/tier 映射以 `backend/.control_plane/pmflow-routing-rules.json` 为唯一配置源。
|
||||
- AI 路由与供应商产品化控制台 v1 只做治理聚合、脱敏展示、团队策略保存和只读诊断:团队策略保存到 `integrations(type='ai_team_policy')`,不得在本阶段改写 `llm_service` runtime 路由、`backend/.control_plane/pmflow-routing-rules.json` 或 provider secret。
|
||||
- 用户可见层必须使用产品语义表达 AI 模式与状态,例如“快速处理 / 深度分析 / 项目记忆查询 / 外部实时查询 / 待确认执行”。成员视图不得暴露 `low/high`、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;负责人视图只看业务场景策略;管理员控制台可在高级详情中查看函数级路线,但默认隐藏 `function_name`。
|
||||
- AI 路由诊断允许提示“环境变量 Key 可能覆盖租户 Key”等风险,但只能返回 masked key 和中文风险说明;禁止返回 prompt、raw error、raw provider payload、raw env value 或明文 token。
|
||||
- `/ai-config` 用户可见定位为团队级 AI 运营中心,不是项目级管理后台。AI 供应商、团队 AI 策略和路由诊断影响当前团队空间内所有项目;项目级页面只能展示 AI 处理模式、来源/可信状态和待确认状态,禁止提供项目级 provider/model/路由覆盖入口。
|
||||
|
||||
### 3.5 Requirement Plan / Draft / Followup
|
||||
- `requirement_plan` 必须是多轮状态机,不得退回单轮模板问答。
|
||||
|
|
@ -105,7 +111,7 @@
|
|||
- orphan requirement-plan followup 在显式 `reply/thread` 存在时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”。
|
||||
- Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。
|
||||
- requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。
|
||||
- `POST /ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 对 requirement-plan 的前台可见状态必须直接投影 `plan_mode / plan_status / requirement_plan_context_summary`;Inbox 与其它前端入口不得再靠 `reply_template_key`、草稿 prose 或 stage 文案自行猜测当前是否仍在需求模式。
|
||||
- `POST /ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 对 requirement-plan 的前台可见状态必须直接投影 `plan_mode / plan_status / requirement_plan_context_summary`;Space legacy adapter 与其它前端入口不得再靠 `reply_template_key`、草稿 prose 或 stage 文案自行猜测当前是否仍在需求模式。独立收件页已退场,旧 `waiting_manual / draft_pending_confirm` 只能作为兼容读模型接入 `/space` 或回到主流程。
|
||||
- requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。
|
||||
- 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 若因 provider 异常、空结构化结果或网络抖动失败,`_resolve_intent` 不得直接把首轮线程打成 `error_template`;必须集中降级为带 `semantic_planner_degraded=true` 审计标记的中性 `query.answer` 场景。后续运行时必须优先重试 semantic backfill;若命中显式 active requirement context / reply anchor,仍留在 requirement-plan 主链,否则继续按普通 QA 处理,禁止无锚点把事实问句硬降级成 requirement consultation。
|
||||
- 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。
|
||||
|
|
@ -186,8 +192,10 @@
|
|||
|
||||
### 3.7 Signal / Project / SSTC / Briefing
|
||||
- `SignalSourceRegistry` 仅作为信号源接入/幂等内部表;用户可见的项目“信号源/信号”统一落到正式 `Signal`。
|
||||
- `create_project_from_natural_language` 必须支持透传 `requested_member_user_ids`,草稿确认时必须把推荐负责人并入项目创建事务。
|
||||
- AI Steward `project-from-text` 归一化阶段必须优先走人员能力图谱,禁止绕过能力图谱直接拍脑袋分配。
|
||||
- `create_project_from_natural_language` 仅可作为 Launch draft enrichment / 内部迁移能力复用,必须支持透传 `requested_member_user_ids`;草稿确认时必须把推荐负责人并入项目创建事务。
|
||||
- `POST /ai-steward/project-from-text` 已下线为 410 迁移响应;任何新建项目不得绕过 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`,不得直接从自然语言写正式 `Project / Task / Signal`。
|
||||
- AI 任务分配与执行建议必须优先使用 Execution Capability Graph:先判断 `ai_direct / ai_with_user_acceptance / ai_with_technical_review / needs_human_judgement / blocked`,再给执行或审查建议;禁止仅凭静态技能标签、编程语言标签或旧团队负载百分比硬派任务。缺 repo、执行包、测试命令、工具连接、回写证据或 reviewer 时必须返回 `blocked` / `needs_human_judgement` 或明确 warning,禁止冒充可执行或已分配成功。
|
||||
- AI 分配和容量判断必须使用 Adaptive Human Capacity:每日 `suggestedBaseline` 必须由当前任务池动态生成,禁止固定深度判断/审查/验收/协调比例;总容量用有效时间判断硬上限,类型占比只用于疲劳和质量风险,不得作为固定槽位限制。旧 `UserWorkload.workload_ratio` 只可作为历史成本参考,不能直接判定可分配。负责人 override 必须先模拟、再应用,且必须包含原因、审计和过期时间;加班只增加时间预算,不清除疲劳风险、高风险密度或证据不足 warning。
|
||||
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量结构化分析失败,必须返回失败安全态。
|
||||
- SSTC 宪章 AI 预填充必须输出并持久化 `field_status/field_evidence/readiness/derived_artifacts`;总确认前必须校验 `readiness`。
|
||||
- SSTC 发布验证清单必须优先消费 `derived_artifacts.verification_metrics`,禁止回退默认验收项。
|
||||
|
|
@ -211,7 +219,7 @@
|
|||
- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话。
|
||||
- 真实飞书网页 E2E 只负责浏览器发消息与页面诊断,结果校验统一走数据库探针。
|
||||
- 显式命名的新项目请求不得仅因正文出现历史项目标题就自动绑定已有 `project_id`。
|
||||
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
||||
- 项目创建返回的 `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
||||
- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统。
|
||||
- `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送。
|
||||
- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { api } from "@/lib/api";
|
||||
|
||||
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||
|
||||
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
|
||||
return (raw as ApiEnvelope<T>).data;
|
||||
}
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
export type AIRoutingStrategy = "quality_first" | "balanced" | "speed_first";
|
||||
export type AIRoutingSeverity = "ok" | "warning" | "critical";
|
||||
|
||||
export interface AIRoutingSlot {
|
||||
tier: "low" | "high";
|
||||
label: string;
|
||||
provider: string;
|
||||
provider_label: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
configured: boolean;
|
||||
config_source: string;
|
||||
api_key_masked?: string;
|
||||
}
|
||||
|
||||
export interface AIRoutingMetricWindow {
|
||||
total_calls: number;
|
||||
failed_calls: number;
|
||||
failure_rate: number;
|
||||
upgraded_calls: number;
|
||||
upgrade_rate: number;
|
||||
structured_failed_calls: number;
|
||||
estimated_cost: number;
|
||||
average_latency_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface AIRoutingDiagnostic {
|
||||
code: string;
|
||||
severity: AIRoutingSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AIRoutingOverview {
|
||||
config_version: string;
|
||||
config_updated_at: string;
|
||||
source_of_truth: string;
|
||||
slots: AIRoutingSlot[];
|
||||
metrics: {
|
||||
last_24h: AIRoutingMetricWindow;
|
||||
last_7d: AIRoutingMetricWindow;
|
||||
};
|
||||
diagnostics: AIRoutingDiagnostic[];
|
||||
latency_status: string;
|
||||
}
|
||||
|
||||
export interface AITeamPolicyScene {
|
||||
key: string;
|
||||
label: string;
|
||||
policy: string;
|
||||
mode: string;
|
||||
human_confirmation: boolean;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export interface AITeamPolicy {
|
||||
strategy: AIRoutingStrategy;
|
||||
strategy_label: string;
|
||||
scenes: AITeamPolicyScene[];
|
||||
automation_boundary: string;
|
||||
budget_guard: {
|
||||
enabled: boolean;
|
||||
monthly_budget_cny?: number | null;
|
||||
alert_threshold_ratio: number;
|
||||
};
|
||||
quality_targets: string[];
|
||||
updated_at?: string | null;
|
||||
updated_by?: number | null;
|
||||
}
|
||||
|
||||
export interface AIRoutingProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
provider_label: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
api_key_masked: string;
|
||||
active: boolean;
|
||||
status_label: string;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AIRoutingSceneFunction {
|
||||
function_name: string;
|
||||
display_name: string;
|
||||
tier: "low" | "high";
|
||||
slot_label: string;
|
||||
source: "default" | "override";
|
||||
rule_name?: string | null;
|
||||
}
|
||||
|
||||
export interface AIRoutingSceneRoute {
|
||||
scene_key: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
primary_slot_label: string;
|
||||
functions: AIRoutingSceneFunction[];
|
||||
}
|
||||
|
||||
export interface AIRoutingRecentFailure {
|
||||
function_label: string;
|
||||
function_name: string;
|
||||
status_label: string;
|
||||
planned_slot: string;
|
||||
executed_slot: string;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AIRoutingAdminConsole {
|
||||
overview: AIRoutingOverview;
|
||||
providers: AIRoutingProvider[];
|
||||
scene_routes: AIRoutingSceneRoute[];
|
||||
protected_functions: string[];
|
||||
recent_failures: AIRoutingRecentFailure[];
|
||||
diagnostics: AIRoutingDiagnostic[];
|
||||
audit_hint: string;
|
||||
}
|
||||
|
||||
export interface AIRoutingDiagnosticsRun {
|
||||
checked_at: string;
|
||||
items: AIRoutingDiagnostic[];
|
||||
summary: {
|
||||
critical: number;
|
||||
warning: number;
|
||||
ok: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const aiRoutingApi = {
|
||||
async getOverview(): Promise<AIRoutingOverview> {
|
||||
const { data } = await api.get<ApiEnvelope<AIRoutingOverview>>("/ai-routing/overview");
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getTeamPolicy(): Promise<AITeamPolicy> {
|
||||
const { data } = await api.get<ApiEnvelope<AITeamPolicy>>("/ai-routing/team-policy");
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async updateTeamPolicy(payload: Pick<AITeamPolicy, "strategy" | "scenes" | "automation_boundary" | "budget_guard" | "quality_targets">): Promise<AITeamPolicy> {
|
||||
const { data } = await api.put<ApiEnvelope<AITeamPolicy>>("/ai-routing/team-policy", payload);
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getAdminConsole(): Promise<AIRoutingAdminConsole> {
|
||||
const { data } = await api.get<ApiEnvelope<AIRoutingAdminConsole>>("/ai-routing/admin-console");
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async runDiagnostics(): Promise<AIRoutingDiagnosticsRun> {
|
||||
const { data } = await api.post<ApiEnvelope<AIRoutingDiagnosticsRun>>("/ai-routing/diagnostics/run", {});
|
||||
return unwrap(data);
|
||||
},
|
||||
};
|
||||
|
|
@ -9,66 +9,6 @@ function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
|||
return raw as T;
|
||||
}
|
||||
|
||||
export interface ProjectFromTextRequest {
|
||||
input_text: string;
|
||||
}
|
||||
|
||||
export interface ProjectFromTextRequirementItem {
|
||||
section: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: string;
|
||||
source_kind?: string;
|
||||
source_name?: string;
|
||||
source_identifier?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFromTextSignalSource {
|
||||
registry_id: number | string;
|
||||
id?: number | string;
|
||||
project_id?: number | string;
|
||||
signal_id?: number | string | null;
|
||||
source_kind: string;
|
||||
source_name: string;
|
||||
source_identifier: string;
|
||||
display_source_kind?: string | null;
|
||||
technical_identifier?: string | null;
|
||||
description?: string | null;
|
||||
requested_via?: string;
|
||||
status?: string;
|
||||
onboarding_status?: string;
|
||||
section?: string;
|
||||
title?: string;
|
||||
priority?: string;
|
||||
origin?: string;
|
||||
raw_context?: Record<string, unknown> | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ProjectFromTextResponse {
|
||||
project: { id: string; name: string; title: string; description: string; status: string };
|
||||
signal: { id: string; content: string; impact_score: number };
|
||||
charter: { id: string; title: string; content: string | Record<string, unknown> | null };
|
||||
tasks: Array<{ id: string; title: string; type: string; estimated_hours: number; priority: string }>;
|
||||
risks: Array<{ type: string; description: string; severity: string }>;
|
||||
signal_sources: ProjectFromTextSignalSource[];
|
||||
analysis: {
|
||||
project_name: string;
|
||||
project_title?: string;
|
||||
tech_stack: string[];
|
||||
timeline: string;
|
||||
primary_role: string;
|
||||
target_audience?: string;
|
||||
domain_id?: number;
|
||||
key_features?: string[];
|
||||
competitors?: string[];
|
||||
success_metrics?: string[];
|
||||
core_pain_points?: string[];
|
||||
mvp_features?: string[];
|
||||
requirement_items?: ProjectFromTextRequirementItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SignalSourceRepairRequest {
|
||||
project_id: number;
|
||||
registry_ids: number[];
|
||||
|
|
@ -700,11 +640,4 @@ export const aiStewardApi = {
|
|||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async createProjectFromText(body: ProjectFromTextRequest): Promise<ProjectFromTextResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<ProjectFromTextResponse>>("/ai-steward/project-from-text", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type {
|
||||
ChangeItem,
|
||||
PMFlowCapacityOverridePayload,
|
||||
PMFlowCapacityOverrideRecord,
|
||||
PMFlowCapacityOverrideSimulation,
|
||||
PMFlowDailyCapacityPlan,
|
||||
PMFlowExecutionContext,
|
||||
PMFlowExecutionCapabilitySnapshot,
|
||||
PMFlowReviewDecisionAction,
|
||||
PMFlowRiskLevel,
|
||||
ProjectTruthState,
|
||||
|
|
@ -28,7 +33,12 @@ function extractErrorMessage(payload: unknown, fallback: string): string {
|
|||
|
||||
function storedAuthToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem("auth_token") || window.localStorage.getItem("token") || window.localStorage.getItem("access_token");
|
||||
return (
|
||||
window.localStorage.getItem("pmflow_token") ||
|
||||
window.localStorage.getItem("auth_token") ||
|
||||
window.localStorage.getItem("token") ||
|
||||
window.localStorage.getItem("access_token")
|
||||
);
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, init: { method: "GET" | "POST"; body?: unknown }): Promise<T> {
|
||||
|
|
@ -446,6 +456,38 @@ export async function getPMFlowExecutionSummary(projectId: string): Promise<PMFl
|
|||
return get<PMFlowExecutionSummary>(`/pmflow-os/projects/${projectId}/execution-summary`);
|
||||
}
|
||||
|
||||
export async function getPMFlowExecutionCapabilities(projectId: string): Promise<PMFlowExecutionCapabilitySnapshot> {
|
||||
return get<PMFlowExecutionCapabilitySnapshot>(`/pmflow-os/projects/${projectId}/execution-capabilities`);
|
||||
}
|
||||
|
||||
export async function getPMFlowCapacityPlan(projectId: string, date?: string): Promise<PMFlowDailyCapacityPlan> {
|
||||
const params = new URLSearchParams();
|
||||
if (date) params.set("date", date);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return get<PMFlowDailyCapacityPlan>(`/pmflow-os/projects/${projectId}/capacity-plan${suffix}`);
|
||||
}
|
||||
|
||||
export async function simulatePMFlowCapacityOverride(
|
||||
projectId: string,
|
||||
payload: PMFlowCapacityOverridePayload
|
||||
): Promise<PMFlowCapacityOverrideSimulation> {
|
||||
return post<PMFlowCapacityOverrideSimulation>(`/pmflow-os/projects/${projectId}/capacity-overrides/simulate`, payload);
|
||||
}
|
||||
|
||||
export async function createPMFlowCapacityOverride(
|
||||
projectId: string,
|
||||
payload: PMFlowCapacityOverridePayload
|
||||
): Promise<PMFlowCapacityOverrideRecord> {
|
||||
return post<PMFlowCapacityOverrideRecord>(`/pmflow-os/projects/${projectId}/capacity-overrides`, payload);
|
||||
}
|
||||
|
||||
export async function getPMFlowCapacityOverrides(projectId: string, date?: string): Promise<PMFlowCapacityOverrideRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (date) params.set("date", date);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return get<PMFlowCapacityOverrideRecord[]>(`/pmflow-os/projects/${projectId}/capacity-overrides${suffix}`);
|
||||
}
|
||||
|
||||
export async function createPMFlowExecutionHandoff(
|
||||
projectId: string,
|
||||
payload: PMFlowExecutionHandoffRequest
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import { getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowExecutionSummary } from "./pmflowOs";
|
||||
import {
|
||||
getPMFlowExecutionCapabilities,
|
||||
getPMFlowExecutionSummary,
|
||||
getPMFlowTruthState,
|
||||
type PMFlowExecutionSummary,
|
||||
} from "./pmflowOs";
|
||||
import { aiPmoApi, type AIPMOWorkItem } from "./aiPmo";
|
||||
import { projectsApi, type ProjectSummary } from "./projects";
|
||||
import type {
|
||||
ChangeItem,
|
||||
MetricSignal,
|
||||
PMFlowExecutionStatus,
|
||||
PMFlowObjectStatus,
|
||||
PMFlowMemberCapacityPlan,
|
||||
PMFlowReviewerRoutingItem,
|
||||
PMFlowExecutionCapabilitySnapshot,
|
||||
Release,
|
||||
ProjectTruthState,
|
||||
} from "@/features/pmflow-os/types";
|
||||
|
|
@ -94,13 +102,50 @@ export interface SpaceIterationDraftAttentionItem {
|
|||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceExecutionReadinessItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
status: "ready" | "partial" | "blocked" | "unknown";
|
||||
title: string;
|
||||
summary: string;
|
||||
connectedTools: string[];
|
||||
waitingForUser: {
|
||||
pendingConfirmations: number;
|
||||
pendingReviews: number;
|
||||
waitingAcceptance: number;
|
||||
};
|
||||
blockers: string[];
|
||||
recommendedNextStep: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceHumanCapacityItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
status: PMFlowMemberCapacityPlan["capacityStatus"];
|
||||
effectiveMinutes: number;
|
||||
plannedMinutes: number;
|
||||
remainingMinutes: number;
|
||||
bottleneckType: PMFlowMemberCapacityPlan["bottleneckType"];
|
||||
suggestedBaseline: PMFlowMemberCapacityPlan["suggestedBaseline"];
|
||||
currentMix: PMFlowMemberCapacityPlan["currentMix"];
|
||||
canTakeMore: boolean;
|
||||
aiReductionPotentialMinutes: number;
|
||||
explanations: string[];
|
||||
recommendations: string[];
|
||||
href: string;
|
||||
}
|
||||
|
||||
export type SpaceNextActionKind =
|
||||
| "pending_confirmation"
|
||||
| "review_attention"
|
||||
| "release_blocked"
|
||||
| "execution_attention"
|
||||
| "iteration_draft"
|
||||
| "feedback_signal";
|
||||
| "feedback_signal"
|
||||
| "legacy_manual_gate";
|
||||
|
||||
export interface SpaceNextActionQueueItem {
|
||||
id: string;
|
||||
|
|
@ -137,16 +182,31 @@ export interface SpaceSavedIdea {
|
|||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceLegacyProjectScopeItem {
|
||||
id: string;
|
||||
workItemId: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
sourceSystem: string;
|
||||
sourceMessageText?: string | null;
|
||||
sourceSender?: string | null;
|
||||
sourceChannelRef?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SpaceWorkbench {
|
||||
projects: SpaceWorkbenchProject[];
|
||||
nextActionQueue: SpaceNextActionQueueItem[];
|
||||
pendingConfirmations: SpacePendingConfirmation[];
|
||||
aiProgress: SpaceAIProgressItem[];
|
||||
executionReadiness: SpaceExecutionReadinessItem[];
|
||||
humanCapacity: SpaceHumanCapacityItem[];
|
||||
releaseAttention: SpaceReleaseAttentionItem[];
|
||||
feedbackAttention: SpaceFeedbackAttentionItem[];
|
||||
iterationDraftAttention: SpaceIterationDraftAttentionItem[];
|
||||
savedIdeas: SpaceSavedIdea[];
|
||||
drafts: SpaceDraftItem[];
|
||||
legacyProjectScopeItems: SpaceLegacyProjectScopeItem[];
|
||||
recentProjects: SpaceWorkbenchProject[];
|
||||
warnings: string[];
|
||||
generatedAt: string;
|
||||
|
|
@ -156,6 +216,7 @@ interface ProjectSnapshot {
|
|||
project: ProjectSummary;
|
||||
truth: ProjectTruthState | null;
|
||||
execution: PMFlowExecutionSummary | null;
|
||||
capabilities: PMFlowExecutionCapabilitySnapshot | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +459,88 @@ function deriveAIProgress(snapshot: ProjectSnapshot): SpaceAIProgressItem[] {
|
|||
return items;
|
||||
}
|
||||
|
||||
function deriveExecutionReadiness(snapshot: ProjectSnapshot): SpaceExecutionReadinessItem[] {
|
||||
const { project, truth, capabilities } = snapshot;
|
||||
if (!capabilities) return [];
|
||||
const readiness = capabilities.personalReadiness;
|
||||
const projectId = String(project.id);
|
||||
const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`;
|
||||
const tools = readiness.toolReadiness
|
||||
.filter((item) => item.status === "ready")
|
||||
.map((item) => item.label)
|
||||
.slice(0, 4);
|
||||
const waiting = readiness.waitingForUser || {};
|
||||
return [
|
||||
{
|
||||
id: `execution-readiness-${projectId}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
status: readiness.status,
|
||||
title: "执行准备度",
|
||||
summary: readiness.recommendedNextStep,
|
||||
connectedTools: tools,
|
||||
waitingForUser: {
|
||||
pendingConfirmations: Number(waiting.pendingConfirmations || 0),
|
||||
pendingReviews: Number(waiting.pendingReviews || 0),
|
||||
waitingAcceptance: Number(waiting.waitingAcceptance || 0),
|
||||
},
|
||||
blockers: readiness.blockers.slice(0, 3),
|
||||
recommendedNextStep: readiness.recommendedNextStep,
|
||||
href: readiness.targetHref || executionHref(projectId),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function storedCurrentUserId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const direct = window.localStorage.getItem("pmflow_user");
|
||||
const persisted = window.localStorage.getItem("pmflow-auth");
|
||||
for (const raw of [direct, persisted]) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { id?: unknown; state?: { user?: { id?: unknown } }; user?: { id?: unknown } };
|
||||
const id = parsed.id ?? parsed.user?.id ?? parsed.state?.user?.id;
|
||||
if (id != null) return String(id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveHumanCapacity(snapshot: ProjectSnapshot): SpaceHumanCapacityItem[] {
|
||||
const { project, truth, capabilities } = snapshot;
|
||||
const plan = capabilities?.adaptiveCapacitySummary;
|
||||
if (!plan || plan.memberPlans.length === 0) return [];
|
||||
const projectId = String(project.id);
|
||||
const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`;
|
||||
const currentUserId = storedCurrentUserId();
|
||||
const memberPlan = (currentUserId ? plan.memberPlans.find((item) => item.userId === currentUserId) : null) ?? plan.memberPlans[0];
|
||||
if (!memberPlan) return [];
|
||||
const aiReductionPotentialMinutes = plan.humanTouches
|
||||
.filter((touch) => !touch.assignedUserId || touch.assignedUserId === memberPlan.userId)
|
||||
.reduce((sum, touch) => sum + Number(touch.aiReductionPotentialMinutes || 0), 0);
|
||||
return [
|
||||
{
|
||||
id: `human-capacity-${projectId}-${memberPlan.userId}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
status: memberPlan.capacityStatus,
|
||||
effectiveMinutes: memberPlan.effectiveMinutes,
|
||||
plannedMinutes: memberPlan.plannedTouchMinutes.total,
|
||||
remainingMinutes: memberPlan.remainingMinutes,
|
||||
bottleneckType: memberPlan.bottleneckType,
|
||||
suggestedBaseline: memberPlan.suggestedBaseline,
|
||||
currentMix: memberPlan.currentMix,
|
||||
canTakeMore: memberPlan.canTakeMore.some((item) => item.status === "recommended"),
|
||||
aiReductionPotentialMinutes,
|
||||
explanations: memberPlan.explanations.slice(0, 3),
|
||||
recommendations: plan.recommendations.slice(0, 2),
|
||||
href: `/team-layer?projectId=${projectId}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function releaseAttentionTitle(status: SpaceReleaseAttentionItem["gateStatus"]): string {
|
||||
if (status === "blocked") return "发布门禁阻塞";
|
||||
if (status === "ready") return "发布候选";
|
||||
|
|
@ -728,6 +871,110 @@ function sortByDate<T extends { updatedAt?: string; updated_at?: string; created
|
|||
return [...items].sort((a, b) => dateValue(b) - dateValue(a));
|
||||
}
|
||||
|
||||
function workItemUpdatedAt(item: AIPMOWorkItem): string {
|
||||
return item.updated_at || item.created_at || new Date().toISOString();
|
||||
}
|
||||
|
||||
function isLegacyActionWorkItem(item: AIPMOWorkItem): boolean {
|
||||
const state = String(item.state || "").toLowerCase();
|
||||
return state === "waiting_manual" || state === "draft_pending_confirm";
|
||||
}
|
||||
|
||||
function isMissingProjectScopeWorkItem(item: AIPMOWorkItem): boolean {
|
||||
const state = String(item.state || "").toLowerCase();
|
||||
const gateCode = String(item.latest_policy_gate_code || "").toLowerCase();
|
||||
return state === "waiting_manual" && gateCode === "missing_project_scope";
|
||||
}
|
||||
|
||||
function legacyWorkItemTitle(item: AIPMOWorkItem): string {
|
||||
const state = String(item.state || "").toLowerCase();
|
||||
if (state === "draft_pending_confirm") {
|
||||
const draftTitle = typeof item.draft_content?.title === "string" ? item.draft_content.title.trim() : "";
|
||||
return draftTitle || item.title || "草稿待确认";
|
||||
}
|
||||
if (isMissingProjectScopeWorkItem(item)) return "需要补充项目归属";
|
||||
return item.title || "旧工作项待处理";
|
||||
}
|
||||
|
||||
function legacyWorkItemSummary(item: AIPMOWorkItem): string {
|
||||
const draftSummary = typeof item.draft_content?.summary === "string" ? item.draft_content.summary.trim() : "";
|
||||
return draftSummary || item.summary || item.ai_plan || item.next_step_text || item.source_message_text || "旧 AI PMO 工作项仍需人工处理。";
|
||||
}
|
||||
|
||||
function legacyProjectTitle(item: AIPMOWorkItem, projectsById: Map<string, ProjectSummary>): string {
|
||||
const projectId = item.project_id ? String(item.project_id) : "";
|
||||
if (item.project_name) return item.project_name;
|
||||
if (projectId && projectsById.has(projectId)) return projectsById.get(projectId)?.title || `项目 ${projectId}`;
|
||||
return "未绑定项目";
|
||||
}
|
||||
|
||||
function legacyWorkItemHref(item: AIPMOWorkItem): string {
|
||||
if (item.project_id) {
|
||||
return `/space?project_id=${encodeURIComponent(String(item.project_id))}&legacyWorkItemId=${encodeURIComponent(String(item.id))}`;
|
||||
}
|
||||
return `/space?legacyWorkItemId=${encodeURIComponent(String(item.id))}`;
|
||||
}
|
||||
|
||||
function deriveLegacyWorkItemActions(
|
||||
items: AIPMOWorkItem[],
|
||||
projectsById: Map<string, ProjectSummary>
|
||||
): SpaceNextActionQueueItem[] {
|
||||
return items
|
||||
.filter(isLegacyActionWorkItem)
|
||||
.map((item): SpaceNextActionQueueItem => {
|
||||
const state = String(item.state || "").toLowerCase();
|
||||
const missingProject = isMissingProjectScopeWorkItem(item);
|
||||
const severity: "low" | "medium" | "high" = missingProject ? "high" : state === "draft_pending_confirm" ? "medium" : "medium";
|
||||
const projectId = item.project_id ? String(item.project_id) : "unbound";
|
||||
return {
|
||||
id: `next-action-legacy-work-item-${item.id}`,
|
||||
projectId,
|
||||
projectTitle: legacyProjectTitle(item, projectsById),
|
||||
kind: "legacy_manual_gate",
|
||||
title: legacyWorkItemTitle(item),
|
||||
summary: legacyWorkItemSummary(item),
|
||||
categoryLabel: missingProject ? "补项目归属" : state === "draft_pending_confirm" ? "草稿待确认" : "旧工作项",
|
||||
priorityLabel: nextActionPriorityLabel(severity),
|
||||
priorityRank: missingProject ? 18 : state === "draft_pending_confirm" ? 28 : 42,
|
||||
severity,
|
||||
updatedAt: workItemUpdatedAt(item),
|
||||
href: legacyWorkItemHref(item),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deriveLegacyDrafts(items: AIPMOWorkItem[], projectsById: Map<string, ProjectSummary>): SpaceDraftItem[] {
|
||||
return items
|
||||
.filter((item) => String(item.state || "").toLowerCase() === "draft_pending_confirm")
|
||||
.map((item): SpaceDraftItem => ({
|
||||
id: `legacy-draft-work-item-${item.id}`,
|
||||
projectId: item.project_id ? String(item.project_id) : "unbound",
|
||||
projectTitle: legacyProjectTitle(item, projectsById),
|
||||
title: legacyWorkItemTitle(item),
|
||||
summary: legacyWorkItemSummary(item),
|
||||
objectType: "旧草稿",
|
||||
status: "draft",
|
||||
updatedAt: workItemUpdatedAt(item),
|
||||
href: legacyWorkItemHref(item),
|
||||
}));
|
||||
}
|
||||
|
||||
function deriveLegacyProjectScopeItems(items: AIPMOWorkItem[]): SpaceLegacyProjectScopeItem[] {
|
||||
return items
|
||||
.filter(isMissingProjectScopeWorkItem)
|
||||
.map((item): SpaceLegacyProjectScopeItem => ({
|
||||
id: `legacy-project-scope-${item.id}`,
|
||||
workItemId: item.id,
|
||||
title: legacyWorkItemTitle(item),
|
||||
summary: legacyWorkItemSummary(item),
|
||||
sourceSystem: item.source_system || "PMFlow",
|
||||
sourceMessageText: item.source_message_text,
|
||||
sourceSender: item.source_sender,
|
||||
sourceChannelRef: item.source_channel_ref,
|
||||
updatedAt: workItemUpdatedAt(item),
|
||||
}));
|
||||
}
|
||||
|
||||
function nextActionPriorityLabel(severity: "low" | "medium" | "high"): string {
|
||||
if (severity === "high") return "高优先级";
|
||||
if (severity === "medium") return "中优先级";
|
||||
|
|
@ -910,6 +1157,7 @@ function buildNextActionQueue({
|
|||
iterationDraftAttention,
|
||||
reviewAttentionActions,
|
||||
staleExecutionActions,
|
||||
legacyActions,
|
||||
}: {
|
||||
pendingConfirmations: SpacePendingConfirmation[];
|
||||
aiProgress: SpaceAIProgressItem[];
|
||||
|
|
@ -918,6 +1166,7 @@ function buildNextActionQueue({
|
|||
iterationDraftAttention: SpaceIterationDraftAttentionItem[];
|
||||
reviewAttentionActions: SpaceNextActionQueueItem[];
|
||||
staleExecutionActions: SpaceNextActionQueueItem[];
|
||||
legacyActions: SpaceNextActionQueueItem[];
|
||||
}): SpaceNextActionQueueItem[] {
|
||||
const pendingActions = pendingConfirmations.map((item): SpaceNextActionQueueItem => {
|
||||
const severity = item.riskLevel;
|
||||
|
|
@ -1000,7 +1249,7 @@ function buildNextActionQueue({
|
|||
}));
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...reviewAttentionActions, ...pendingActions, ...releaseBlockedActions, ...staleExecutionActions, ...activeExecutionActions, ...iterationActions, ...feedbackActions]
|
||||
return [...reviewAttentionActions, ...legacyActions, ...pendingActions, ...releaseBlockedActions, ...staleExecutionActions, ...activeExecutionActions, ...iterationActions, ...feedbackActions]
|
||||
.filter((item) => {
|
||||
const key = `${item.kind}:${item.href}:${item.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
|
|
@ -1013,31 +1262,50 @@ function buildNextActionQueue({
|
|||
|
||||
async function getProjectSnapshot(project: ProjectSummary): Promise<ProjectSnapshot> {
|
||||
const projectId = String(project.id);
|
||||
const [truthResult, executionResult] = await Promise.allSettled([
|
||||
const [truthResult, executionResult, capabilitiesResult] = await Promise.allSettled([
|
||||
getPMFlowTruthState(projectId),
|
||||
getPMFlowExecutionSummary(projectId),
|
||||
getPMFlowExecutionCapabilities(projectId),
|
||||
]);
|
||||
const warnings: string[] = [];
|
||||
const truth = truthResult.status === "fulfilled" ? truthResult.value : null;
|
||||
const execution = executionResult.status === "fulfilled" ? executionResult.value : null;
|
||||
const capabilities = capabilitiesResult.status === "fulfilled" ? capabilitiesResult.value : null;
|
||||
if (truthResult.status === "rejected") warnings.push(`${project.title}: 项目真相摘要未同步`);
|
||||
if (executionResult.status === "rejected") warnings.push(`${project.title}: 执行摘要未同步`);
|
||||
if (capabilitiesResult.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 };
|
||||
if (capabilities?.warnings.length) warnings.push(...capabilities.warnings.map((item) => `${project.title}: ${item}`));
|
||||
return { project, truth, execution, capabilities, warnings };
|
||||
}
|
||||
|
||||
export const spaceApi = {
|
||||
async getWorkbench(): Promise<SpaceWorkbench> {
|
||||
const summary = await projectsApi.getSummary({ status: "active" });
|
||||
const projects = sortByDate(summary.projects).slice(0, 8);
|
||||
const snapshots = await Promise.all(projects.map(getProjectSnapshot));
|
||||
const projectsById = new Map(projects.map((project) => [String(project.id), project]));
|
||||
const [snapshots, legacyWorkItemsResult] = await Promise.all([
|
||||
Promise.all(projects.map(getProjectSnapshot)),
|
||||
aiPmoApi
|
||||
.listWorkItems({ limit: 80 })
|
||||
.then((payload) => ({ payload, warning: null as string | null }))
|
||||
.catch(() => ({
|
||||
payload: { items: [] as AIPMOWorkItem[] },
|
||||
warning: "旧 AI PMO 工作项未同步,个人空间只展示新主链待处理项。",
|
||||
})),
|
||||
]);
|
||||
const legacyWorkItems = legacyWorkItemsResult.payload.items || [];
|
||||
const legacyActions = sortByDate(deriveLegacyWorkItemActions(legacyWorkItems, projectsById)).slice(0, 8);
|
||||
const legacyProjectScopeItems = sortByDate(deriveLegacyProjectScopeItems(legacyWorkItems)).slice(0, 6);
|
||||
const workbenchProjects = snapshots.map(deriveProject);
|
||||
const pendingConfirmations = sortByDate(snapshots.flatMap(derivePending)).slice(0, 8);
|
||||
const aiProgress = sortByDate(snapshots.flatMap(deriveAIProgress)).slice(0, 8);
|
||||
const executionReadiness = snapshots.flatMap(deriveExecutionReadiness).slice(0, 4);
|
||||
const humanCapacity = snapshots.flatMap(deriveHumanCapacity).slice(0, 4);
|
||||
const releaseAttention = sortByDate(snapshots.flatMap(deriveReleaseAttention)).slice(0, 8);
|
||||
const feedbackAttention = sortByDate(snapshots.flatMap(deriveFeedbackAttention)).slice(0, 8);
|
||||
const iterationDraftAttention = sortByDate(snapshots.flatMap(deriveIterationDraftAttention)).slice(0, 8);
|
||||
|
|
@ -1053,17 +1321,22 @@ export const spaceApi = {
|
|||
iterationDraftAttention,
|
||||
reviewAttentionActions,
|
||||
staleExecutionActions,
|
||||
legacyActions,
|
||||
}),
|
||||
pendingConfirmations,
|
||||
aiProgress,
|
||||
executionReadiness,
|
||||
humanCapacity,
|
||||
releaseAttention,
|
||||
feedbackAttention,
|
||||
iterationDraftAttention,
|
||||
savedIdeas: [],
|
||||
drafts: sortByDate(snapshots.flatMap(deriveDrafts)).slice(0, 8),
|
||||
drafts: sortByDate([...snapshots.flatMap(deriveDrafts), ...deriveLegacyDrafts(legacyWorkItems, projectsById)]).slice(0, 8),
|
||||
legacyProjectScopeItems,
|
||||
recentProjects: sortByDate(workbenchProjects),
|
||||
warnings: [
|
||||
"暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。",
|
||||
...(legacyWorkItemsResult.warning ? [legacyWorkItemsResult.warning] : []),
|
||||
...snapshots.flatMap((snapshot) => snapshot.warnings),
|
||||
].slice(0, 8),
|
||||
generatedAt: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { AuthBoundary, ManagerBoundary } from "@/auth/RouteBoundary";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{location.pathname + location.search}</div>;
|
||||
}
|
||||
|
||||
function BoundaryRoutes({ initialEntry }: { initialEntry: string }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LocationProbe />} />
|
||||
<Route
|
||||
path="/space"
|
||||
element={
|
||||
<AuthBoundary>
|
||||
<div>space-ok</div>
|
||||
</AuthBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/project-launch"
|
||||
element={
|
||||
<AuthBoundary allowProjectLaunchDemo>
|
||||
<div>launch-ok</div>
|
||||
</AuthBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ai-config/model-routing-config"
|
||||
element={
|
||||
<ManagerBoundary>
|
||||
<div>manager-ok</div>
|
||||
</ManagerBoundary>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RouteBoundary", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
useAuthStore.setState({ token: null, user: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
useAuthStore.setState({ token: null, user: null });
|
||||
});
|
||||
|
||||
it("redirects unauthenticated space access to login with redirect", () => {
|
||||
render(<BoundaryRoutes initialEntry="/space" />);
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/login?redirect=%2Fspace");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated real project launch to login", () => {
|
||||
render(<BoundaryRoutes initialEntry="/project-launch" />);
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/login?redirect=%2Fproject-launch");
|
||||
});
|
||||
|
||||
it("allows project launch demo without login", () => {
|
||||
render(<BoundaryRoutes initialEntry="/project-launch?demo=1" />);
|
||||
expect(screen.getByText("launch-ok")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows permission fallback for non-manager users", () => {
|
||||
useAuthStore.setState({ token: "token", user: { id: "1", username: "member", role: "BUILDER" } });
|
||||
|
||||
render(<BoundaryRoutes initialEntry="/ai-config/model-routing-config" />);
|
||||
|
||||
expect(screen.getByText("当前账号没有执行此操作的权限")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function isProjectLaunchDemo(search: string): boolean {
|
||||
const params = new URLSearchParams(search);
|
||||
return params.get("demo") === "1" || params.get("dataSource") === "demo";
|
||||
}
|
||||
|
||||
export function AuthBoundary({
|
||||
children,
|
||||
allowProjectLaunchDemo = false,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
allowProjectLaunchDemo?: boolean;
|
||||
}) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const location = useLocation();
|
||||
if (!token && allowProjectLaunchDemo && isProjectLaunchDemo(location.search)) {
|
||||
return children;
|
||||
}
|
||||
if (!token) {
|
||||
const redirect = location.pathname + location.search;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(redirect || "/space")}`} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export function ManagerBoundary({ children }: { children: JSX.Element }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const location = useLocation();
|
||||
if (!user?.id) {
|
||||
const redirect = location.pathname + location.search;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(redirect || "/space")}`} replace />;
|
||||
}
|
||||
if (!isManagerRole(user.role)) {
|
||||
return <PermissionDeniedSurface />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export function PermissionDeniedSurface() {
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f6f8fb] px-5 text-slate-950">
|
||||
<section className="w-full max-w-[520px] rounded-2xl border border-[#dfe7f3] bg-white p-8 shadow-[0_18px_50px_rgba(15,23,42,0.08)]">
|
||||
<div className="inline-flex rounded-full bg-red-50 px-3 py-1 text-sm font-semibold text-red-500">权限不足</div>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-[-0.03em]">当前账号没有执行此操作的权限</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">
|
||||
该页面属于团队管理或系统治理能力,需要 OWNER 或 REVIEWER 权限。你可以返回个人空间,或切换到具备权限的账号。
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Button asChild className="rounded-xl bg-[#2463EB] hover:bg-[#1f58d5]">
|
||||
<a href="/space">返回个人空间</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl"
|
||||
onClick={() => {
|
||||
logout();
|
||||
window.location.href = "/login?redirect=/space";
|
||||
}}
|
||||
>
|
||||
切换账号
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ describe("resolveQAPageAnchor", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("uses query project filter for inbox routes", () => {
|
||||
expect(resolveQAPageAnchor("/inbox", "?project_id=19")).toMatchObject({
|
||||
it("uses query project filter for space routes", () => {
|
||||
expect(resolveQAPageAnchor("/space", "?project_id=19")).toMatchObject({
|
||||
projectId: 19,
|
||||
sourceChatId: null,
|
||||
anchorType: "project_filter",
|
||||
|
|
@ -30,12 +30,11 @@ describe("resolveQAPageAnchor", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("builds anchored ai steward urls for project entry points", () => {
|
||||
expect(buildAIStewardQAUrl({ projectId: 17 })).toBe("/ai-steward?tab=qa&project_id=17");
|
||||
it("builds anchored space urls for project entry points", () => {
|
||||
expect(buildAIStewardQAUrl({ projectId: 17 })).toBe("/space?view=ai-history&project_id=17");
|
||||
expect(buildAIStewardQAUrl({ projectId: "23", sourceChatId: "chat-23" })).toBe(
|
||||
"/ai-steward?tab=qa&project_id=23&source_chat_id=chat-23",
|
||||
"/space?view=ai-history&project_id=23&source_chat_id=chat-23",
|
||||
);
|
||||
expect(buildAIStewardQAUrl()).toBe("/ai-steward?tab=qa");
|
||||
expect(buildAIStewardQAUrl()).toBe("/space?view=ai-history");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function buildAIStewardQAUrl(params?: {
|
|||
sourceChatId?: string | null;
|
||||
}): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("tab", "qa");
|
||||
searchParams.set("view", "ai-history");
|
||||
|
||||
const projectId = parsePositiveInt(
|
||||
typeof params?.projectId === "string" || typeof params?.projectId === "number"
|
||||
|
|
@ -51,7 +51,7 @@ export function buildAIStewardQAUrl(params?: {
|
|||
if (sourceChatId) {
|
||||
searchParams.set("source_chat_id", sourceChatId);
|
||||
}
|
||||
return `/ai-steward?${searchParams.toString()}`;
|
||||
return `/space?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
export function resolveQAPageAnchor(pathname: string, search: string): QAPageAnchor {
|
||||
|
|
@ -99,4 +99,3 @@ export function resolveQAPageAnchor(pathname: string, search: string): QAPageAnc
|
|||
origin: "none",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface InboxProjectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface InboxActionPanelProps {
|
||||
selectedProjectId: string;
|
||||
projectOptions: InboxProjectOption[];
|
||||
loadingProjects?: boolean;
|
||||
projectsErrorText?: string | null;
|
||||
pending: boolean;
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function InboxActionPanel({
|
||||
selectedProjectId,
|
||||
projectOptions,
|
||||
loadingProjects = false,
|
||||
projectsErrorText = null,
|
||||
pending,
|
||||
onSelectProject,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: InboxActionPanelProps) {
|
||||
const isDisabled = pending || loadingProjects || projectOptions.length === 0;
|
||||
const canConfirm = !isDisabled && Boolean(selectedProjectId);
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-800">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<span>信号源接入缺少项目归属</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-slate-600">请选择关联项目:</div>
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(event) => onSelectProject(event.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="">{loadingProjects ? "加载项目中..." : "请选择项目"}</option>
|
||||
{projectOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{projectsErrorText ? (
|
||||
<p className="mt-2 text-xs text-red-600">{projectsErrorText}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={onCancel}
|
||||
disabled={pending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
{pending ? "处理中…" : "确认关联"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import InboxListItem from "@/components/inbox/InboxListItem";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import type { InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
function buildItem(): InboxItemViewModel {
|
||||
return {
|
||||
id: "work-item-19",
|
||||
source_id: "19",
|
||||
source_kind: "ai_pmo_work_item",
|
||||
display_kind: "action_required",
|
||||
title: "换季搭配推荐",
|
||||
summary: "先把需求继续收完整。",
|
||||
ai_plan: "继续收敛需求范围",
|
||||
blocking_reason: null,
|
||||
primary_action: { kind: "confirm", label: "确认入库" },
|
||||
secondary_action: { kind: "revise_draft", label: "修改" },
|
||||
weak_action: { kind: "cancel_draft", label: "取消" },
|
||||
project_id: 52,
|
||||
project_name: "换季推荐项目",
|
||||
priority: "high",
|
||||
priority_rank: 3,
|
||||
is_read: false,
|
||||
dedupe_count: 1,
|
||||
can_undo: false,
|
||||
undo_deadline: null,
|
||||
target_url: "/projects/52/board",
|
||||
created_at: "2026-04-17T10:00:00+08:00",
|
||||
updated_at: "2026-04-17T10:05:00+08:00",
|
||||
stage_text: "需求收集中",
|
||||
next_step_text: "先补齐衣橱标签和反馈采集方式",
|
||||
latest_policy_gate_code: null,
|
||||
approval_requirement: null,
|
||||
plan_mode: true,
|
||||
plan_status: "collecting",
|
||||
requirement_plan_context_summary: {
|
||||
title: "换季搭配推荐",
|
||||
goal: "先验证用户是否愿意采用推荐搭配。",
|
||||
missing_info: ["衣橱标签", "反馈采集方式"],
|
||||
question_round: 2,
|
||||
last_round_field: "target_user",
|
||||
},
|
||||
source_work_item: {
|
||||
id: 19,
|
||||
source_system: "pmflow_manual",
|
||||
source_message_id: "msg-plan-19",
|
||||
source_message_text: "帮我继续收这个需求",
|
||||
source_conversation_closure: null,
|
||||
source_captured_at: "2026-04-17T10:00:00+08:00",
|
||||
source_sender: "产品经理A",
|
||||
source_channel_ref: "pmflow_manual",
|
||||
closure_summary: null,
|
||||
implementation_modules: [],
|
||||
source_closure_turns: [],
|
||||
state: "draft_pending_confirm",
|
||||
intent: "query.answer",
|
||||
decision_level: "project",
|
||||
project_id: 52,
|
||||
project_name: "换季推荐项目",
|
||||
signal_id: null,
|
||||
reply_template_key: "requirement_plan_followup",
|
||||
title: "换季搭配推荐",
|
||||
summary: "先把需求继续收完整。",
|
||||
ai_plan: "继续收敛需求范围",
|
||||
blocking_reason: null,
|
||||
next_step_text: "先补齐衣橱标签和反馈采集方式",
|
||||
stage_text: "需求收集中",
|
||||
priority: "high",
|
||||
priority_rank: 3,
|
||||
target_url: "/projects/52/board",
|
||||
review_summary: null,
|
||||
execution_result: null,
|
||||
draft_content: null,
|
||||
draft_expires_at: null,
|
||||
draft_version: 2,
|
||||
confirm_message_id: "om-plan-19",
|
||||
latest_policy_gate_code: null,
|
||||
approval_requirement: null,
|
||||
created_at: "2026-04-17T10:00:00+08:00",
|
||||
updated_at: "2026-04-17T10:05:00+08:00",
|
||||
plan_mode: true,
|
||||
plan_status: "collecting",
|
||||
requirement_plan_context_summary: {
|
||||
title: "换季搭配推荐",
|
||||
goal: "先验证用户是否愿意采用推荐搭配。",
|
||||
missing_info: ["衣橱标签", "反馈采集方式"],
|
||||
question_round: 2,
|
||||
last_round_field: "target_user",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("InboxListItem", () => {
|
||||
it("renders planmode status and requirement summary cues", () => {
|
||||
renderWithProviders(
|
||||
<InboxListItem
|
||||
item={buildItem()}
|
||||
expanded
|
||||
pendingActionKey={null}
|
||||
onToggleExpand={vi.fn()}
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("当前模式")).toBeTruthy();
|
||||
expect(screen.queryByText("需求模式")).toBeTruthy();
|
||||
expect(screen.queryByText("collecting")).toBeTruthy();
|
||||
expect(screen.getAllByText(/换季搭配推荐/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/衣橱标签/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { BellRing, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, LoaderCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatInboxAbsoluteTime, formatInboxTime } from "@/lib/formatInboxTime";
|
||||
import {
|
||||
mapDisplayKindLabel,
|
||||
mapIntentLabel,
|
||||
mapStageText,
|
||||
normalizeProjectLabel,
|
||||
sanitizeInboxSubtitle,
|
||||
sanitizeInboxTitle,
|
||||
} from "@/lib/inboxTextMap";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
const STATUS_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700",
|
||||
auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700",
|
||||
auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700",
|
||||
info: "border-slate-300 bg-slate-100 text-slate-600",
|
||||
};
|
||||
|
||||
const STATUS_ICON_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "bg-amber-100 text-amber-700",
|
||||
auto_running: "bg-sky-100 text-sky-700",
|
||||
auto_done: "bg-emerald-100 text-emerald-700",
|
||||
info: "bg-slate-200 text-slate-600",
|
||||
};
|
||||
|
||||
function formatTechnicalValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") return value.trim() || "-";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusIcon(displayKind: InboxDisplayKind) {
|
||||
if (displayKind === "action_required") return CircleAlert;
|
||||
if (displayKind === "auto_running") return LoaderCircle;
|
||||
if (displayKind === "auto_done") return CheckCircle2;
|
||||
return BellRing;
|
||||
}
|
||||
|
||||
export function buildInboxActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string {
|
||||
return `${item.id}:${action.kind}:${action.label}`;
|
||||
}
|
||||
|
||||
interface InboxListItemProps {
|
||||
item: InboxItemViewModel;
|
||||
expanded: boolean;
|
||||
pendingActionKey: string | null;
|
||||
inlinePanel?: ReactNode;
|
||||
onToggleExpand: (itemId: string) => void;
|
||||
onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void;
|
||||
}
|
||||
|
||||
export default function InboxListItem({
|
||||
item,
|
||||
expanded,
|
||||
pendingActionKey,
|
||||
inlinePanel,
|
||||
onToggleExpand,
|
||||
onAction,
|
||||
}: InboxListItemProps) {
|
||||
const isActionRow = item.display_kind === "action_required";
|
||||
const projectLabel = normalizeProjectLabel(item.project_name, item.project_id);
|
||||
const title = sanitizeInboxTitle(item.title);
|
||||
const subtitle = sanitizeInboxSubtitle(item.summary);
|
||||
const StatusIcon = resolveStatusIcon(item.display_kind);
|
||||
|
||||
const payload = (item.source_notification?.payload ?? {}) as Record<string, unknown>;
|
||||
const sourceMessageText =
|
||||
String(item.source_work_item?.source_message_text || item.source_mounted_run?.final_reply || payload.message_text || payload.raw_message || "").trim();
|
||||
const closureDialogue = String(item.source_work_item?.source_conversation_closure || "").trim();
|
||||
const closureSummary = String(item.source_work_item?.closure_summary || "").trim();
|
||||
const implementationModules = Array.isArray(item.source_work_item?.implementation_modules)
|
||||
? item.source_work_item?.implementation_modules
|
||||
.map((module) => String(module || "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
: [];
|
||||
const sourceCapturedAt =
|
||||
String(
|
||||
item.source_work_item?.source_captured_at ||
|
||||
payload.captured_at ||
|
||||
payload.occurred_at ||
|
||||
""
|
||||
).trim() || null;
|
||||
const sourceSender =
|
||||
String(item.source_work_item?.source_sender || payload.sender || payload.operator || "").trim() || "-";
|
||||
const sourceChannel =
|
||||
String(item.source_work_item?.source_channel_ref || payload.channel_ref || payload.source_channel || "").trim() || "-";
|
||||
const sourceMessageId =
|
||||
String(item.source_work_item?.source_message_id || item.source_mounted_run?.source_message_id || payload.source_message_id || payload.message_id || "").trim() || "-";
|
||||
const sourceSystem =
|
||||
String(item.source_work_item?.source_system || item.source_mounted_run?.source_system || item.source_notification?.source || payload.source_system || "PMFlow").trim();
|
||||
const timelineAnchor = sourceCapturedAt || item.updated_at || item.created_at;
|
||||
const displayTime = formatInboxTime(timelineAnchor);
|
||||
const eventTime = formatInboxAbsoluteTime(timelineAnchor);
|
||||
const queuedTime = formatInboxAbsoluteTime(item.created_at || item.updated_at);
|
||||
const rawIntent = String(item.source_work_item?.intent || item.source_mounted_run?.trigger_reason || payload.intent || "").trim();
|
||||
const typedActions = payload.typed_actions ?? payload["typed actions"] ?? payload.typedActions;
|
||||
const chatId = payload.chat_id ?? payload.source_chat_id ?? payload.chatId;
|
||||
const requirementSummary = sanitizeInboxSubtitle(closureSummary || item.summary || item.ai_plan || item.next_step_text || "暂无摘要");
|
||||
const executionSummary = sanitizeInboxSubtitle(item.ai_plan || item.next_step_text || item.summary || "暂无");
|
||||
const readableInput =
|
||||
closureDialogue ||
|
||||
sourceMessageText ||
|
||||
item.source_mounted_run?.final_reply ||
|
||||
((item.source_work_item || item.source_mounted_run) ? sanitizeInboxTitle(item.title) : "-");
|
||||
const undoDeadlineText = item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销";
|
||||
const requirementPlanSummary =
|
||||
item.requirement_plan_context_summary ||
|
||||
item.source_work_item?.requirement_plan_context_summary ||
|
||||
null;
|
||||
const planMode = Boolean(item.plan_mode ?? item.source_work_item?.plan_mode);
|
||||
const planStatus = String(item.plan_status || item.source_work_item?.plan_status || "").trim() || null;
|
||||
const planTitle = String(requirementPlanSummary?.title || "").trim();
|
||||
const planGoal = String(requirementPlanSummary?.goal || "").trim();
|
||||
const planMissingInfo = Array.isArray(requirementPlanSummary?.missing_info)
|
||||
? requirementPlanSummary.missing_info.map((entry) => String(entry || "").trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
const planQuestionRound = Number(requirementPlanSummary?.question_round || 0) || null;
|
||||
const planLastRoundField = String(requirementPlanSummary?.last_round_field || "").trim() || null;
|
||||
const showPlanModeCard = Boolean(planMode || planStatus || planTitle || planGoal || planMissingInfo.length);
|
||||
|
||||
const primaryActionKey = buildInboxActionKey(item, item.primary_action);
|
||||
const secondaryActionKey = buildInboxActionKey(item, item.secondary_action);
|
||||
const weakActionKey = buildInboxActionKey(item, item.weak_action);
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onToggleExpand(item.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onToggleExpand(item.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 md:px-4",
|
||||
isActionRow ? "min-h-[72px]" : "min-h-[52px]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={cn("mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full", STATUS_ICON_STYLES[item.display_kind])}>
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", item.display_kind === "auto_running" && "animate-spin")} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-slate-900">{title}</p>
|
||||
<div className="ml-2 shrink-0 text-right">
|
||||
<div className="max-w-[120px] truncate text-[11px] text-slate-500 sm:max-w-[180px]">{projectLabel}</div>
|
||||
<div className="text-[11px] text-slate-400">{displayTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="line-clamp-1 min-w-0 text-xs text-slate-500">{subtitle}</p>
|
||||
<Badge variant="outline" className={cn("shrink-0 text-[11px]", STATUS_STYLES[item.display_kind])}>
|
||||
{mapDisplayKindLabel(item.display_kind)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
|
||||
{isActionRow && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
|
||||
disabled={pendingActionKey === primaryActionKey}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAction(item, item.primary_action);
|
||||
}}
|
||||
>
|
||||
{pendingActionKey === primaryActionKey ? "处理中…" : item.primary_action.label}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpand(item.id);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
{expanded ? <ChevronUp className="ml-1 h-3.5 w-3.5" /> : <ChevronDown className="ml-1 h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-3 py-3 md:px-4">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-white px-3 py-2">
|
||||
<div className="text-[11px] font-semibold text-slate-500">当前阶段</div>
|
||||
<div className="mt-1 text-sm text-slate-700">{mapStageText(item.stage_text || mapDisplayKindLabel(item.display_kind))}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2">
|
||||
<div className="text-[11px] font-semibold text-slate-500">下一步</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-slate-700">{sanitizeInboxSubtitle(item.next_step_text || item.ai_plan || "暂无")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="text-[11px] font-semibold text-slate-500">消息闭环(原始对话)</div>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{readableInput || "-"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="text-[11px] font-semibold text-slate-500">需求摘要(接口总结)</div>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{requirementSummary}</p>
|
||||
<div className="mt-2 border-t border-slate-100 pt-2">
|
||||
<div className="text-[11px] font-semibold text-slate-500">执行摘要</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{executionSummary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPlanModeCard && (
|
||||
<div className="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-slate-500">当前模式</div>
|
||||
<div className="mt-1 text-sm text-slate-800">{planMode ? "需求模式" : "普通执行模式"}</div>
|
||||
</div>
|
||||
{planStatus ? <Badge variant="outline">{planStatus}</Badge> : null}
|
||||
</div>
|
||||
{planTitle ? (
|
||||
<div className="mt-3">
|
||||
<div className="text-[11px] font-semibold text-slate-500">当前需求</div>
|
||||
<div className="mt-1 text-sm text-slate-800">{planTitle}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{planGoal ? (
|
||||
<div className="mt-3">
|
||||
<div className="text-[11px] font-semibold text-slate-500">目标</div>
|
||||
<div className="mt-1 text-sm leading-6 text-slate-700">{planGoal}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{planMissingInfo.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<div className="text-[11px] font-semibold text-slate-500">待补信息</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{planMissingInfo.map((field) => (
|
||||
<span
|
||||
key={`${item.id}-${field}`}
|
||||
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800"
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{planQuestionRound || planLastRoundField ? (
|
||||
<div className="mt-3 text-xs text-slate-600">
|
||||
{planQuestionRound ? <span>当前轮次:第 {planQuestionRound} 轮</span> : null}
|
||||
{planQuestionRound && planLastRoundField ? <span> · </span> : null}
|
||||
{planLastRoundField ? <span>最近收敛字段:{planLastRoundField}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{implementationModules.length > 0 && (
|
||||
<div className="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="text-[11px] font-semibold text-slate-500">实现模块(闭环提炼)</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{implementationModules.map((module) => (
|
||||
<span
|
||||
key={`${item.id}-${module}`}
|
||||
className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700"
|
||||
>
|
||||
{module}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-slate-500">事件时间</div>
|
||||
<div className="mt-1 text-sm text-slate-800">{eventTime}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-slate-500">入队时间</div>
|
||||
<div className="mt-1 text-sm text-slate-800">{queuedTime}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-slate-500">来源</div>
|
||||
<div className="mt-1 text-sm text-slate-800">{sourceSystem || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-slate-500">来源消息ID</div>
|
||||
<div className="mt-1 truncate text-sm text-slate-800">{sourceMessageId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
时间说明:优先显示源系统事件时间(captured_at),无记录时回退为系统更新时间。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details className="mt-2 rounded-lg border border-dashed border-slate-200 bg-white px-3 py-2">
|
||||
<summary className="cursor-pointer text-[11px] font-semibold text-slate-500">技术字段(点击展开)</summary>
|
||||
<div className="mt-2 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
|
||||
<div>intent(可读):{mapIntentLabel(rawIntent)}</div>
|
||||
<div>intent(原始):{rawIntent || "-"}</div>
|
||||
<div>typed_actions:{formatTechnicalValue(typedActions)}</div>
|
||||
<div>chat_id:{formatTechnicalValue(chatId)}</div>
|
||||
<div>source_sender:{sourceSender}</div>
|
||||
<div>source_channel:{sourceChannel}</div>
|
||||
<div>重复次数:{item.dedupe_count}</div>
|
||||
<div>撤销状态:{undoDeadlineText}</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{inlinePanel}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!isActionRow && item.primary_action.kind !== "none" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
|
||||
disabled={pendingActionKey === primaryActionKey}
|
||||
onClick={() => onAction(item, item.primary_action)}
|
||||
>
|
||||
{pendingActionKey === primaryActionKey ? "处理中…" : item.primary_action.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.secondary_action.kind !== "none" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={pendingActionKey === secondaryActionKey}
|
||||
onClick={() => onAction(item, item.secondary_action)}
|
||||
>
|
||||
{pendingActionKey === secondaryActionKey ? "处理中…" : item.secondary_action.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.weak_action.kind === "none" ? (
|
||||
<span className="text-xs text-slate-500">{item.weak_action.label}</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-slate-600 hover:bg-slate-100"
|
||||
disabled={pendingActionKey === weakActionKey}
|
||||
onClick={() => onAction(item, item.weak_action)}
|
||||
>
|
||||
{pendingActionKey === weakActionKey ? "处理中…" : item.weak_action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
const removedLegacyLaunchLabel = "旧" + "启动台";
|
||||
const removedLegacyProjectLabel = "旧" + "项目面";
|
||||
|
||||
vi.mock("@/features/pmflow-os/store", () => ({
|
||||
usePMFlowOsStore: () => ({
|
||||
state: {
|
||||
changeItems: [],
|
||||
project: { stage: "idea" },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Sidebar product navigation", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({ token: null, user: null });
|
||||
});
|
||||
|
||||
it("hides AI operations center for member users", () => {
|
||||
useAuthStore.setState({
|
||||
token: "token",
|
||||
user: { id: "9", username: "member", role: "BUILDER" },
|
||||
});
|
||||
|
||||
renderWithProviders(<Sidebar open onClose={vi.fn()} />, ["/dashboard"]);
|
||||
|
||||
expect(screen.getByText("团队层")).toBeInTheDocument();
|
||||
expect(screen.queryByText("新建项目")).toBeNull();
|
||||
expect(screen.queryByText(removedLegacyLaunchLabel)).toBeNull();
|
||||
expect(screen.queryByText(removedLegacyProjectLabel)).toBeNull();
|
||||
expect(screen.queryByText("团队负载")).toBeNull();
|
||||
expect(screen.queryByText("AI 运营中心")).toBeNull();
|
||||
expect(screen.queryByText("用户管理")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows team operations and AI operations center for managers", () => {
|
||||
useAuthStore.setState({
|
||||
token: "token",
|
||||
user: { id: "7", username: "owner", role: "OWNER" },
|
||||
});
|
||||
|
||||
renderWithProviders(<Sidebar open onClose={vi.fn()} />, ["/dashboard"]);
|
||||
|
||||
expect(screen.getByText("团队层")).toBeInTheDocument();
|
||||
expect(screen.queryByText("新建项目")).toBeNull();
|
||||
expect(screen.queryByText(removedLegacyLaunchLabel)).toBeNull();
|
||||
expect(screen.queryByText(removedLegacyProjectLabel)).toBeNull();
|
||||
expect(screen.queryByText("团队负载")).toBeNull();
|
||||
expect(screen.queryByText("能力管理")).toBeNull();
|
||||
expect(screen.queryByText("用户管理")).toBeNull();
|
||||
expect(screen.getByText("AI 运营")).toBeInTheDocument();
|
||||
expect(screen.getByText("AI 运营中心")).toBeInTheDocument();
|
||||
expect(screen.queryByText("管理后台")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bot,
|
||||
BookMarked,
|
||||
FolderKanban,
|
||||
GitBranchPlus,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
LineChart,
|
||||
Moon,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Sliders,
|
||||
|
|
@ -27,28 +24,17 @@ import { usePMFlowOsStore } from "@/features/pmflow-os/store";
|
|||
import type { PMFlowStage } from "@/features/pmflow-os/types";
|
||||
|
||||
const primaryNavItems = [
|
||||
{ to: "/project-launch", label: "新建项目", icon: Plus },
|
||||
{ to: "/command-center", label: "指挥台", icon: Sparkles },
|
||||
{ to: "/work-graph", label: "项目工作台", icon: FolderKanban },
|
||||
{ to: "/change-review", label: "决策与待确认", icon: ShieldCheck },
|
||||
{ to: "/execution-bridge", label: "开发执行", icon: GitBranchPlus },
|
||||
{ to: "/release-growth", label: "发布与增长", icon: LineChart },
|
||||
{ to: "/team-layer", label: "团队协作", icon: Users },
|
||||
{ to: "/team-layer", label: "团队层", icon: Users },
|
||||
{ to: "/project-memory", label: "项目记忆", icon: BookMarked },
|
||||
];
|
||||
|
||||
const projectNavItems = primaryNavItems.filter((item) => item.to !== "/project-launch");
|
||||
|
||||
const legacyNavItems = [
|
||||
{ to: "/ai-steward", label: "旧启动台", icon: Bot },
|
||||
{ to: "/projects", label: "旧项目面", icon: FolderKanban },
|
||||
{ to: "/inbox", label: "消息台", icon: Inbox },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ to: "/workloads", label: "团队负载", icon: LayoutDashboard },
|
||||
{ to: "/skills", label: "能力管理", icon: LineChart },
|
||||
{ to: "/ai-config", label: "管理后台", icon: Sliders },
|
||||
const aiOpsNavItems = [
|
||||
{ to: "/ai-config", label: "AI 运营中心", icon: Sliders },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
|
|
@ -120,6 +106,12 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||
</NavLink>
|
||||
);
|
||||
|
||||
const renderSectionLabel = (label: string) => (
|
||||
<div className="px-4 pb-2 text-[11px] font-semibold tracking-[0.16em] text-slate-400">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
|
|
@ -143,7 +135,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||
<nav className="mt-8 min-h-0 flex-1 overflow-y-auto [-webkit-overflow-scrolling:touch]">
|
||||
{isPMFlowOsSurface ? (
|
||||
<>
|
||||
<div className="space-y-2">{projectNavItems.map((item) => renderNavItem({ ...item, badge: item.to === "/change-review" && pendingCount > 0 ? pendingCount : undefined }))}</div>
|
||||
<div className="space-y-2">{primaryNavItems.map((item) => renderNavItem({ ...item, badge: item.to === "/change-review" && pendingCount > 0 ? pendingCount : undefined }))}</div>
|
||||
|
||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||
|
||||
|
|
@ -153,27 +145,21 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||
<SidebarStageRail activeStage={state.project.stage} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||
|
||||
<div className="space-y-2">
|
||||
{legacyNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{primaryNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||
<div className="space-y-2">
|
||||
{legacyNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||
<div className="space-y-2">
|
||||
{adminNavItems.map(renderNavItem)}
|
||||
{isManager ? renderNavItem({ to: "/users", label: "用户管理", icon: Users }) : null}
|
||||
</div>
|
||||
{isManager ? (
|
||||
<>
|
||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||
<div className="space-y-2">
|
||||
{renderSectionLabel("AI 运营")}
|
||||
{aiOpsNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { ArrowLeft, Bell, LogOut, Menu, User } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
|
||||
import { sstcApi, type SSTCRisks } from "@/api/sstc";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { notificationsApi, type NotificationListPayload } from "@/api/notifications";
|
||||
import { SSTCRiskTopbarEntry } from "@/components/sstc/SSTCRiskFloatPanel";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { parseBackendDate } from "@/lib/datetime";
|
||||
import { zhCN } from "@/lib/zhCN";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -18,19 +13,9 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildInboxView } from "@/lib/inbox";
|
||||
import type { InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
|
||||
|
||||
const POLL_INTERVAL_MS = 15000;
|
||||
const EMPTY_NOTIFICATIONS: NotificationListPayload = {
|
||||
items: [],
|
||||
unread_count: 0,
|
||||
};
|
||||
const EMPTY_WORK_ITEMS: AIPMOWorkItemListPayload = {
|
||||
items: [],
|
||||
};
|
||||
const EMPTY_PROJECT_RISKS: SSTCRisks = {
|
||||
conflicts: [],
|
||||
risks: [],
|
||||
|
|
@ -42,27 +27,6 @@ const EMPTY_PROJECT_RISKS: SSTCRisks = {
|
|||
},
|
||||
};
|
||||
|
||||
const SECTION_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||||
auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
info: "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300",
|
||||
};
|
||||
|
||||
const SECTION_LABELS: Record<InboxDisplayKind, string> = {
|
||||
action_required: "需你决策",
|
||||
auto_running: "AI处理中",
|
||||
auto_done: "AI已完成",
|
||||
info: "仅通知",
|
||||
};
|
||||
|
||||
const SECTION_CHROME: Record<InboxDisplayKind, string> = {
|
||||
action_required: "bg-amber-50 text-amber-700 dark:bg-amber-900/35 dark:text-amber-300",
|
||||
auto_running: "bg-sky-50 text-sky-700 dark:bg-sky-900/35 dark:text-sky-300",
|
||||
auto_done: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-300",
|
||||
info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
|
||||
};
|
||||
|
||||
const ROOT_LEVEL_PATHS = new Set([
|
||||
"/command-center",
|
||||
"/change-review",
|
||||
|
|
@ -73,9 +37,7 @@ const ROOT_LEVEL_PATHS = new Set([
|
|||
"/project-memory",
|
||||
"/dashboard",
|
||||
"/workloads",
|
||||
"/inbox",
|
||||
"/projects",
|
||||
"/ai-steward",
|
||||
"/ai-config",
|
||||
"/skills",
|
||||
"/users",
|
||||
|
|
@ -85,20 +47,6 @@ interface TopBarProps {
|
|||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
const date = parseBackendDate(value);
|
||||
if (!date) return "-";
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (diffMs < minute) return "刚刚";
|
||||
if (diffMs < hour) return `${Math.floor(diffMs / minute)} 分钟前`;
|
||||
if (diffMs < day) return `${Math.floor(diffMs / hour)} 小时前`;
|
||||
return `${Math.floor(diffMs / day)} 天前`;
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string) {
|
||||
const normalized = pathname.replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
|
|
@ -129,34 +77,10 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [notifications, setNotifications] = useState<NotificationListPayload>(EMPTY_NOTIFICATIONS);
|
||||
const [workItems, setWorkItems] = useState<AIPMOWorkItemListPayload>(EMPTY_WORK_ITEMS);
|
||||
const maxSeenBadgeRef = useRef<number>(0);
|
||||
const latestBellStateRef = useRef({
|
||||
notifications: EMPTY_NOTIFICATIONS,
|
||||
workItems: EMPTY_WORK_ITEMS,
|
||||
});
|
||||
|
||||
const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]);
|
||||
const normalizedPathname = useMemo(() => normalizePathname(location.pathname), [location.pathname]);
|
||||
const isPMFlowOsSurface = useMemo(
|
||||
() =>
|
||||
[
|
||||
"/command-center",
|
||||
"/change-review",
|
||||
"/work-graph",
|
||||
"/execution-bridge",
|
||||
"/release-growth",
|
||||
"/team-layer",
|
||||
"/project-memory",
|
||||
].includes(normalizedPathname),
|
||||
[normalizedPathname]
|
||||
);
|
||||
const showBackButton = useMemo(() => !ROOT_LEVEL_PATHS.has(normalizedPathname), [normalizedPathname]);
|
||||
const fallbackBackPath = useMemo(() => resolveFallbackBackPath(normalizedPathname), [normalizedPathname]);
|
||||
const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null;
|
||||
const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox";
|
||||
const { data: boardOverviewPayload } = useQuery({
|
||||
queryKey: ["sstc-topbar-risk-overview", currentProjectId],
|
||||
queryFn: () => sstcApi.getBoardOverview(currentProjectId!),
|
||||
|
|
@ -166,72 +90,6 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
});
|
||||
const currentProjectRisks = boardOverviewPayload?.risks || EMPTY_PROJECT_RISKS;
|
||||
|
||||
const fetchBellData = async (notifyOnNew: boolean) => {
|
||||
const [notificationsResult, workItemsResult] = await Promise.allSettled([
|
||||
notificationsApi.list({ limit: 18 }),
|
||||
aiPmoApi.listWorkItems({ limit: 18 }),
|
||||
]);
|
||||
|
||||
let nextNotifications = latestBellStateRef.current.notifications;
|
||||
let nextWorkItems = latestBellStateRef.current.workItems;
|
||||
|
||||
if (notificationsResult.status === "fulfilled") {
|
||||
nextNotifications = notificationsResult.value;
|
||||
setNotifications(nextNotifications);
|
||||
}
|
||||
|
||||
if (workItemsResult.status === "fulfilled") {
|
||||
nextWorkItems = workItemsResult.value;
|
||||
setWorkItems(nextWorkItems);
|
||||
}
|
||||
|
||||
const nextBadgeCount =
|
||||
nextNotifications.unread_count +
|
||||
nextWorkItems.items.filter((item) => item.state === "waiting_manual").length;
|
||||
if (notifyOnNew && maxSeenBadgeRef.current > 0 && nextBadgeCount > maxSeenBadgeRef.current) {
|
||||
toast({ title: "有新的收件项", variant: "success" });
|
||||
}
|
||||
maxSeenBadgeRef.current = Math.max(maxSeenBadgeRef.current, nextBadgeCount);
|
||||
};
|
||||
|
||||
const openPreviewItem = async (item: InboxItemViewModel) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("section", item.display_kind);
|
||||
if (item.project_id) {
|
||||
params.set("project_id", String(item.project_id));
|
||||
} else if (currentProjectId) {
|
||||
params.set("project_id", currentProjectId);
|
||||
}
|
||||
|
||||
if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) {
|
||||
try {
|
||||
await notificationsApi.markRead(item.source_notification.id);
|
||||
} catch {
|
||||
// Ignore preview read failures and still navigate to inbox.
|
||||
}
|
||||
}
|
||||
navigate(`/inbox?${params.toString()}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
latestBellStateRef.current = { notifications, workItems };
|
||||
}, [notifications, workItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPMFlowOsSurface) {
|
||||
setNotifications(EMPTY_NOTIFICATIONS);
|
||||
setWorkItems(EMPTY_WORK_ITEMS);
|
||||
return;
|
||||
}
|
||||
void fetchBellData(false);
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchBellData(true);
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isPMFlowOsSurface]);
|
||||
|
||||
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
|
|
@ -273,26 +131,18 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={zhCN.bell.title} className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 min-w-4 rounded-full bg-red-500 px-1 text-center text-[10px] leading-4 text-white">
|
||||
{badgeCount > 99 ? "99+" : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-[min(460px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)] dark:bg-slate-950 dark:shadow-none"
|
||||
className="w-[min(360px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)] dark:bg-slate-950 dark:shadow-none"
|
||||
>
|
||||
<div className="rounded-2xl bg-white px-4 py-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{zhCN.bell.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
铃铛只看摘要,完整处理流统一进入 Inbox。
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
未读 {notifications.unread_count} · 待决策 {inboxData.leaderSummary.decision_count}
|
||||
待确认、AI 进行中和发布阻塞统一进入个人空间与主流程处理。
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -301,113 +151,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
|
||||
asChild
|
||||
>
|
||||
<Link to={inboxHref}>{zhCN.bell.viewAll}</Link>
|
||||
<Link to="/space">打开个人空间</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
key: "decision",
|
||||
label: "需你决策",
|
||||
value: inboxData.leaderSummary.decision_count,
|
||||
chrome: "bg-amber-50 text-amber-700 dark:bg-amber-900/35 dark:text-amber-300",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
{
|
||||
key: "running",
|
||||
label: "AI处理中",
|
||||
value: inboxData.leaderSummary.auto_running_count,
|
||||
chrome: "bg-sky-50 text-sky-700 dark:bg-sky-900/35 dark:text-sky-300",
|
||||
icon: LoaderCircle,
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "今日完成",
|
||||
value: inboxData.leaderSummary.auto_done_today,
|
||||
chrome: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-300",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
].map(({ key, label, value, chrome, icon: Icon }) => (
|
||||
<div key={key} className="rounded-xl bg-slate-50 px-3 py-3 dark:bg-slate-800">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-slate-500 dark:text-slate-400">{label}</div>
|
||||
<div className="mt-1 text-lg font-bold text-slate-900 dark:text-slate-100">{value}</div>
|
||||
</div>
|
||||
<span className={cn("inline-flex h-8 w-8 items-center justify-center rounded-full", chrome)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[560px] space-y-3 overflow-auto">
|
||||
{inboxData.previewSections.every((section) => section.count === 0) ? (
|
||||
<div className="rounded-2xl bg-white p-4 text-sm text-slate-500 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:text-slate-400 dark:shadow-none">
|
||||
当前没有需要进入消息台的收件项。
|
||||
</div>
|
||||
) : (
|
||||
inboxData.previewSections.map((section, index) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none"
|
||||
>
|
||||
{index > 0 && <DropdownMenuSeparator className="hidden" />}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full px-3 py-1 text-[11px] font-semibold",
|
||||
SECTION_CHROME[section.key as InboxDisplayKind]
|
||||
)}
|
||||
>
|
||||
{SECTION_LABELS[section.key as InboxDisplayKind]}
|
||||
</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">前 3 条摘要预览</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500">{section.count} 条</div>
|
||||
</div>
|
||||
|
||||
{section.items.length === 0 ? (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
||||
当前分区为空
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{section.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => void openPreviewItem(item)}
|
||||
className="w-full rounded-xl border border-slate-100 bg-slate-50 p-3 text-left transition hover:border-slate-200 hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="outline" className={cn("text-[11px]", SECTION_STYLES[item.display_kind])}>
|
||||
{SECTION_LABELS[item.display_kind]}
|
||||
</Badge>
|
||||
{item.project_id && (
|
||||
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{item.project_name || `项目 #${item.project_id}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{formatRelativeTime(item.updated_at || item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-1 text-sm font-semibold text-slate-900 dark:text-slate-100">{item.title}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500 dark:text-slate-400">{item.summary}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<div className="rounded-2xl bg-white p-4 text-sm leading-6 text-slate-500 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:text-slate-400 dark:shadow-none">
|
||||
需要处理的事项会出现在 `/space` 的“我的下一步”;项目内审查、确认和执行回写仍回到对应主流程页面。
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }:
|
|||
size="sm"
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => navigate(`/ai-steward?tab=feedback-center§ion=task&project_id=${numericProjectId}`)}
|
||||
onClick={() => navigate(`/ai-config?section=feedback-center&project_id=${numericProjectId}`)}
|
||||
>
|
||||
打开反馈中心
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export function SSTCGlobalHeader({
|
|||
|
||||
<Card className="cursor-pointer transition-colors hover:bg-accent/40" onClick={onOpenWorkloads}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">团队负载健康度</CardTitle>
|
||||
<CardTitle className="text-base">容量信号健康度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xl font-semibold sm:text-2xl" style={{ color: ratioColor(ratio) }}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PersonalSpacePage from "./PersonalSpacePage";
|
||||
import { getPMFlowExecutionSummary, getPMFlowTruthState } from "@/api/pmflowOs";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { getPMFlowExecutionCapabilities, getPMFlowExecutionSummary, getPMFlowTruthState } from "@/api/pmflowOs";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { spaceApi, type SpaceWorkbench } from "@/api/space";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
|
@ -15,12 +16,21 @@ vi.mock("@/api/space", () => ({
|
|||
vi.mock("@/api/projects", () => ({
|
||||
projectsApi: {
|
||||
getSummary: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/aiPmo", () => ({
|
||||
aiPmoApi: {
|
||||
listWorkItems: vi.fn(),
|
||||
decideWorkItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/pmflowOs", () => ({
|
||||
getPMFlowTruthState: vi.fn(),
|
||||
getPMFlowExecutionSummary: vi.fn(),
|
||||
getPMFlowExecutionCapabilities: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/pmflow-os/components/MobileBottomNav", () => ({
|
||||
|
|
@ -36,11 +46,14 @@ const emptyWorkbench: SpaceWorkbench = {
|
|||
nextActionQueue: [],
|
||||
pendingConfirmations: [],
|
||||
aiProgress: [],
|
||||
executionReadiness: [],
|
||||
humanCapacity: [],
|
||||
releaseAttention: [],
|
||||
feedbackAttention: [],
|
||||
iterationDraftAttention: [],
|
||||
savedIdeas: [],
|
||||
drafts: [],
|
||||
legacyProjectScopeItems: [],
|
||||
recentProjects: [],
|
||||
warnings: ["暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。"],
|
||||
generatedAt: "2026-04-26T08:00:00.000Z",
|
||||
|
|
@ -49,6 +62,7 @@ const emptyWorkbench: SpaceWorkbench = {
|
|||
describe("PersonalSpacePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(aiPmoApi.listWorkItems).mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
it("renders the read-only Space workbench sections without demo saved ideas", async () => {
|
||||
|
|
@ -195,6 +209,25 @@ describe("PersonalSpacePage", () => {
|
|||
href: "/execution-bridge?projectId=17",
|
||||
},
|
||||
],
|
||||
executionReadiness: [
|
||||
{
|
||||
id: "execution-readiness-17",
|
||||
projectId: "17",
|
||||
projectTitle: "真实项目",
|
||||
status: "partial",
|
||||
title: "执行准备度",
|
||||
summary: "先补齐执行桥的 repo、执行包或回写证据。",
|
||||
connectedTools: ["执行 Prompt Package"],
|
||||
waitingForUser: {
|
||||
pendingConfirmations: 1,
|
||||
pendingReviews: 0,
|
||||
waitingAcceptance: 1,
|
||||
},
|
||||
blockers: ["尚未回写执行证据,完成 IDE/Agent 操作后需要同步进度。"],
|
||||
recommendedNextStep: "先补齐执行桥的 repo、执行包或回写证据。",
|
||||
href: "/execution-bridge?projectId=17",
|
||||
},
|
||||
],
|
||||
releaseAttention: [
|
||||
{
|
||||
id: "release-attention-17",
|
||||
|
|
@ -345,55 +378,58 @@ describe("PersonalSpacePage", () => {
|
|||
renderWithProviders(<PersonalSpacePage />, ["/space"]);
|
||||
|
||||
expect((await screen.findAllByText("我的下一步")).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByRole("link", { name: /进入主流程/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/pmflow-os?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /确认需求变更/ })[0]).toHaveAttribute(
|
||||
await screen.findAllByText("进入主流程");
|
||||
const linkForText = (label: string | RegExp) => {
|
||||
const link = screen.getAllByText(label)[0]?.closest("a");
|
||||
expect(link).not.toBeNull();
|
||||
return link;
|
||||
};
|
||||
expect(linkForText("进入主流程")).toHaveAttribute("href", "/pmflow-os?projectId=17");
|
||||
expect(linkForText("确认需求变更")).toHaveAttribute(
|
||||
"href",
|
||||
"/change-review?projectId=17&changeSetId=91"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /高风险需求变更待确认/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("高风险需求变更待确认")).toHaveAttribute(
|
||||
"href",
|
||||
"/change-review?projectId=17&changeSetId=93"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /执行交接已领取,等待进度回写/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("执行交接已领取,等待进度回写")).toHaveAttribute(
|
||||
"href",
|
||||
"/execution-bridge?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /AI 正在推进执行链路/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("AI 正在推进执行链路")).toHaveAttribute(
|
||||
"href",
|
||||
"/execution-bridge?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /发布验证阻塞/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("发布验证阻塞")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /发布验证待确认/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("发布验证待确认")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=18"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /发布候选/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("发布候选")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=19"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /新反馈信号/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("新反馈信号")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /收入信号更新/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("收入信号更新")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /合规阻塞关注/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("合规阻塞关注")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=17"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /下一轮迭代建议待确认/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("下一轮迭代建议待确认")).toHaveAttribute(
|
||||
"href",
|
||||
"/change-review?projectId=17&changeSetId=92"
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /优化 onboarding 上传入口/ })[0]).toHaveAttribute(
|
||||
expect(linkForText("优化 onboarding 上传入口")).toHaveAttribute(
|
||||
"href",
|
||||
"/release-growth?projectId=17"
|
||||
);
|
||||
|
|
@ -574,6 +610,27 @@ describe("PersonalSpacePage", () => {
|
|||
recentDeliveries: [],
|
||||
warnings: [],
|
||||
} as never);
|
||||
vi.mocked(getPMFlowExecutionCapabilities).mockResolvedValue({
|
||||
projectId: "17",
|
||||
executionModes: [],
|
||||
personalReadiness: {
|
||||
status: "empty",
|
||||
title: "暂无执行准备度",
|
||||
summary: "暂无真实执行能力快照。",
|
||||
toolReadiness: [],
|
||||
waitingForUser: {
|
||||
pendingConfirmations: 0,
|
||||
pendingReviews: 0,
|
||||
waitingAcceptance: 0,
|
||||
},
|
||||
blockers: [],
|
||||
recommendedNextStep: null,
|
||||
targetHref: "/execution-bridge?projectId=17",
|
||||
},
|
||||
teamSignals: [],
|
||||
warnings: [],
|
||||
generatedAt: "2026-04-26T08:00:00.000Z",
|
||||
} as never);
|
||||
|
||||
const { spaceApi: actualSpaceApi } = await vi.importActual<typeof import("@/api/space")>("@/api/space");
|
||||
const workbench = await actualSpaceApi.getWorkbench();
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ import {
|
|||
Sparkles,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
|
||||
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
|
||||
|
|
@ -29,8 +31,11 @@ import {
|
|||
spaceApi,
|
||||
type SpaceAIProgressItem,
|
||||
type SpaceDraftItem,
|
||||
type SpaceExecutionReadinessItem,
|
||||
type SpaceFeedbackAttentionItem,
|
||||
type SpaceHumanCapacityItem,
|
||||
type SpaceIterationDraftAttentionItem,
|
||||
type SpaceLegacyProjectScopeItem,
|
||||
type SpaceNextActionKind,
|
||||
type SpaceNextActionQueueItem,
|
||||
type SpacePendingConfirmation,
|
||||
|
|
@ -38,6 +43,7 @@ import {
|
|||
type SpaceSavedIdea,
|
||||
type SpaceWorkbenchProject,
|
||||
} from "@/api/space";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LIVE_REFRESH_MS = 30_000;
|
||||
|
|
@ -123,7 +129,9 @@ function DesktopSpace({
|
|||
<section className="min-w-0 space-y-6">
|
||||
<NextActionQueuePanel items={data.nextActionQueue} />
|
||||
|
||||
<PanelHeader title="继续推进" description="只聚合项目状态和下一步动作;具体确认、提交、回写都回到主流程。" actionLabel="查看项目列表" actionTo="/projects" />
|
||||
<LegacyProjectScopePanel items={data.legacyProjectScopeItems} onResolved={onRetry} />
|
||||
|
||||
<PanelHeader title="继续推进" description="只聚合项目状态和下一步动作;具体确认、提交、回写都回到主流程。" actionLabel="打开项目索引" actionTo="/projects" />
|
||||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
{data.projects.length ? (
|
||||
data.projects.map((project) => <ProjectCard key={project.id} project={project} />)
|
||||
|
|
@ -162,6 +170,8 @@ function DesktopSpace({
|
|||
</section>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<HumanCapacityRail items={data.humanCapacity} />
|
||||
<ExecutionReadinessRail items={data.executionReadiness} />
|
||||
<AIProgressRail items={data.aiProgress} />
|
||||
<SyncWarnings warnings={data.warnings} onRetry={onRetry} />
|
||||
</aside>
|
||||
|
|
@ -235,6 +245,8 @@ function MobileSpace({
|
|||
</div>
|
||||
</MobileSection>
|
||||
|
||||
<LegacyProjectScopePanel items={data.legacyProjectScopeItems} onResolved={onRetry} compact />
|
||||
|
||||
<MobileSection title="继续推进">
|
||||
{data.projects.length ? (
|
||||
<div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1">
|
||||
|
|
@ -295,6 +307,26 @@ function MobileSpace({
|
|||
</div>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection title="执行准备度" badge={data.executionReadiness.length}>
|
||||
<div className="space-y-3">
|
||||
{data.executionReadiness.length ? (
|
||||
data.executionReadiness.map((item) => <MobileExecutionReadiness key={item.id} item={item} />)
|
||||
) : (
|
||||
<InlineEmpty title="暂无执行准备度" description="接入真实执行能力快照后会显示工具连接、待确认、待验收和阻塞项;不会用技能表冒充。" />
|
||||
)}
|
||||
</div>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection title="我的今日容量" badge={data.humanCapacity.length}>
|
||||
<div className="space-y-3">
|
||||
{data.humanCapacity.length ? (
|
||||
data.humanCapacity.map((item) => <MobileHumanCapacity key={item.id} item={item} />)
|
||||
) : (
|
||||
<InlineEmpty title="尚无真实容量计划" description="接入项目执行能力快照后会显示有效容量、人工触点和系统解释;不会用传统工时或技能表冒充。" />
|
||||
)}
|
||||
</div>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection title="暂存想法" badge={data.savedIdeas.length}>
|
||||
<div className="grid gap-3">
|
||||
{data.savedIdeas.length ? (
|
||||
|
|
@ -421,6 +453,159 @@ function NextActionQueueRow({ item, rank }: { item: SpaceNextActionQueueItem; ra
|
|||
);
|
||||
}
|
||||
|
||||
function LegacyProjectScopePanel({
|
||||
items,
|
||||
onResolved,
|
||||
compact,
|
||||
}: {
|
||||
items: SpaceLegacyProjectScopeItem[];
|
||||
onResolved: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [selectedProjectByItem, setSelectedProjectByItem] = useState<Record<string, string>>({});
|
||||
const [pendingWorkItemId, setPendingWorkItemId] = useState<number | null>(null);
|
||||
const [dismissedIds, setDismissedIds] = useState<string[]>([]);
|
||||
const [noticeByItem, setNoticeByItem] = useState<
|
||||
Record<string, { title: string; variant: "default" | "success" | "error" }>
|
||||
>({});
|
||||
const visibleItems = items.filter((item) => !dismissedIds.includes(item.id));
|
||||
const activeProjectsQuery = useQuery({
|
||||
queryKey: ["projects", "active", "space-legacy-project-scope"],
|
||||
queryFn: () => projectsApi.list({ status: "active" }),
|
||||
enabled: visibleItems.length > 0,
|
||||
});
|
||||
|
||||
if (!visibleItems.length) return null;
|
||||
|
||||
const confirmProject = async (item: SpaceLegacyProjectScopeItem) => {
|
||||
const selectedProjectId = Number(selectedProjectByItem[item.id]);
|
||||
if (!Number.isFinite(selectedProjectId) || selectedProjectId <= 0) {
|
||||
setNoticeByItem((current) => ({
|
||||
...current,
|
||||
[item.id]: { title: "请先选择关联项目", variant: "default" },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setPendingWorkItemId(item.workItemId);
|
||||
try {
|
||||
const result = await aiPmoApi.decideWorkItem(item.workItemId, {
|
||||
action: "approve",
|
||||
project_id: Math.trunc(selectedProjectId),
|
||||
});
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
setNoticeByItem((current) => ({
|
||||
...current,
|
||||
[item.id]: { title: followupReply || "已确认项目归属,AI 将继续处理该信号", variant: "success" },
|
||||
}));
|
||||
setDismissedIds((current) => [...current, item.id]);
|
||||
onResolved();
|
||||
} catch (error) {
|
||||
setNoticeByItem((current) => ({
|
||||
...current,
|
||||
[item.id]: { title: getApiErrorMessage(error, "项目归属确认失败"), variant: "error" },
|
||||
}));
|
||||
} finally {
|
||||
setPendingWorkItemId((current) => (current === item.workItemId ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cn("rounded-[20px] border border-amber-200 bg-amber-50/70", compact ? "mx-4 mt-5 p-4" : "p-5")}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white text-amber-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className={cn("font-semibold tracking-tight text-slate-950", compact ? "text-base" : "text-xl")}>需要补充项目归属</h2>
|
||||
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-semibold text-amber-700">{visibleItems.length}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
这些旧 AI PMO 信号已经进入人工门禁,但缺少项目绑定。确认归属后继续走既有审计链路,不在个人空间直接写项目真相层。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{visibleItems.map((item) => {
|
||||
const pending = pendingWorkItemId === item.workItemId;
|
||||
const selectedProjectId = selectedProjectByItem[item.id] || "";
|
||||
const notice = noticeByItem[item.id];
|
||||
return (
|
||||
<article key={item.id} className="rounded-2xl border border-amber-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-slate-950">{item.title}</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-slate-600">{item.summary}</p>
|
||||
{item.sourceMessageText ? (
|
||||
<p className="mt-2 line-clamp-2 rounded-xl bg-[#f7f9fc] px-3 py-2 text-xs leading-5 text-slate-500">
|
||||
{item.sourceMessageText}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
{item.sourceSystem} · {item.sourceSender || "未知发送人"} · {formatRelativeTime(item.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full shrink-0 space-y-2 lg:w-[280px]">
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
disabled={pending || activeProjectsQuery.isLoading}
|
||||
onChange={(event) => setSelectedProjectByItem((current) => ({ ...current, [item.id]: event.target.value }))}
|
||||
className="h-10 w-full rounded-xl border border-[#dbe5f2] bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
<option value="">{activeProjectsQuery.isLoading ? "加载项目中..." : "选择关联项目"}</option>
|
||||
{(activeProjectsQuery.data || []).map((project) => (
|
||||
<option key={project.id} value={String(project.id)}>
|
||||
{project.title || `项目 ${project.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{activeProjectsQuery.isError ? (
|
||||
<div className="text-xs text-rose-600">{getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败")}</div>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-xs leading-5",
|
||||
notice.variant === "error"
|
||||
? "bg-rose-50 text-rose-600"
|
||||
: notice.variant === "success"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-slate-50 text-slate-500"
|
||||
)}
|
||||
>
|
||||
{notice.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 text-slate-500"
|
||||
disabled={pending}
|
||||
onClick={() => setDismissedIds((current) => [...current, item.id])}
|
||||
>
|
||||
稍后
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-9 rounded-xl bg-[#2463EB] px-3 text-white hover:bg-[#1f58d5]"
|
||||
disabled={pending || !selectedProjectId || activeProjectsQuery.isLoading}
|
||||
onClick={() => void confirmProject(item)}
|
||||
>
|
||||
{pending ? "处理中..." : "确认归属"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project }: { project: SpaceWorkbenchProject }) {
|
||||
const riskClass =
|
||||
project.riskLabel === "高"
|
||||
|
|
@ -518,6 +703,106 @@ function AIProgressRail({ items }: { items: SpaceAIProgressItem[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function HumanCapacityRail({ items }: { items: SpaceHumanCapacityItem[] }) {
|
||||
return (
|
||||
<section className="rounded-[20px] border border-[#dbe5f2] bg-white p-5">
|
||||
<PanelHeader title="我的今日容量" description="按当前任务池动态生成,不使用固定工时槽。" badge={items.length} compact />
|
||||
<div className="mt-5 space-y-3">
|
||||
{items.length ? (
|
||||
items.map((item) => <HumanCapacityCard key={item.id} item={item} />)
|
||||
) : (
|
||||
<InlineEmpty title="尚无真实容量计划" description="接入 execution-capabilities 后会显示今日有效容量、人工触点和系统解释;不会用传统工时冒充。" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HumanCapacityCard({ item }: { item: SpaceHumanCapacityItem }) {
|
||||
const tone = capacityTone(item.status);
|
||||
const utilization = item.effectiveMinutes > 0 ? Math.min(100, Math.round((item.plannedMinutes / item.effectiveMinutes) * 100)) : 0;
|
||||
return (
|
||||
<Link to={item.href} className="block rounded-xl border border-[#edf2f8] bg-[#fbfcff] p-4 transition hover:border-[#cfe0ff] hover:bg-[#f5f8ff]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("rounded-md px-2 py-0.5 text-xs font-semibold", tone.badgeClass)}>{tone.label}</span>
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{item.projectTitle}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-500">
|
||||
已计划 {item.plannedMinutes}min / 有效 {item.effectiveMinutes}min,剩余 {item.remainingMinutes}min
|
||||
</p>
|
||||
</div>
|
||||
<Clock3 className="mt-1 h-4 w-4 shrink-0 text-slate-400" />
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-full bg-slate-100">
|
||||
<div className={cn("h-full rounded-full", tone.barClass, capacityWidthClass(utilization))} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<StatusPill label={`主负载 ${workTypeLabel(item.bottleneckType)}`} />
|
||||
<StatusPill label={item.canTakeMore ? "可继续承接" : "不建议直接加派"} />
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">
|
||||
动态建议:判断 {percent(item.suggestedBaseline.focus)} · 审查 {percent(item.suggestedBaseline.review)} · 验收 {percent(item.suggestedBaseline.acceptance)}
|
||||
</div>
|
||||
{item.aiReductionPotentialMinutes > 0 ? (
|
||||
<div className="mt-2 rounded-lg bg-[#eef4ff] px-3 py-2 text-xs leading-5 text-[#1d4ed8]">
|
||||
AI 预处理预计可释放 {item.aiReductionPotentialMinutes}min 人工触点。
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionReadinessRail({ items }: { items: SpaceExecutionReadinessItem[] }) {
|
||||
return (
|
||||
<section className="rounded-[20px] border border-[#dbe5f2] bg-white p-5">
|
||||
<PanelHeader
|
||||
title="执行准备度"
|
||||
description="只读聚合 AI 执行包、工具连接、待确认和待验收。"
|
||||
badge={items.length}
|
||||
compact
|
||||
/>
|
||||
<div className="mt-5 space-y-3">
|
||||
{items.length ? (
|
||||
items.map((item) => <ExecutionReadinessCard key={item.id} item={item} />)
|
||||
) : (
|
||||
<InlineEmpty title="暂无执行准备度" description="真实 execution-capabilities 快照同步后会显示;不会用传统技能表冒充。" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionReadinessCard({ item }: { item: SpaceExecutionReadinessItem }) {
|
||||
const status = readinessTone(item.status);
|
||||
return (
|
||||
<Link to={item.href} className="block rounded-xl border border-[#edf2f8] bg-[#fbfcff] p-4 transition hover:border-[#cfe0ff] hover:bg-[#f5f8ff]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("rounded-md px-2 py-0.5 text-xs font-semibold", status.badgeClass)}>{status.label}</span>
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{item.projectTitle}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">{item.summary}</p>
|
||||
</div>
|
||||
<ChevronRight className="mt-1 h-4 w-4 shrink-0 text-slate-400" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<StatusPill label={`${item.waitingForUser.pendingConfirmations} 待确认`} />
|
||||
<StatusPill label={`${item.waitingForUser.pendingReviews} 待审查`} />
|
||||
<StatusPill label={`${item.waitingForUser.waitingAcceptance} 待验收`} />
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">
|
||||
工具:{item.connectedTools.length ? item.connectedTools.join(" / ") : "未接入或数据不足"}
|
||||
</div>
|
||||
{item.blockers.length ? (
|
||||
<div className="mt-2 rounded-lg bg-orange-50 px-3 py-2 text-xs leading-5 text-orange-700">{item.blockers[0]}</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseAttentionPanel({ items }: { items: SpaceReleaseAttentionItem[] }) {
|
||||
return (
|
||||
<section className="rounded-[20px] border border-[#dde6f2] bg-white">
|
||||
|
|
@ -880,6 +1165,55 @@ function MobileAIProgress({ item }: { item: SpaceAIProgressItem }) {
|
|||
);
|
||||
}
|
||||
|
||||
function MobileExecutionReadiness({ item }: { item: SpaceExecutionReadinessItem }) {
|
||||
const status = readinessTone(item.status);
|
||||
return (
|
||||
<Link to={item.href} className="block rounded-[18px] border border-[#dbe5f2] bg-white p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl", status.iconClass)}>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{item.projectTitle}</span>
|
||||
<span className={cn("shrink-0 rounded-md px-2 py-1 text-xs font-semibold", status.badgeClass)}>{status.label}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{item.recommendedNextStep}</span>
|
||||
<span className="mt-2 block truncate text-xs text-slate-500">
|
||||
待确认 {item.waitingForUser.pendingConfirmations} · 待审查 {item.waitingForUser.pendingReviews} · 待验收 {item.waitingForUser.waitingAcceptance}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.blockers.length ? <div className="mt-3 rounded-xl bg-orange-50 px-3 py-2 text-xs leading-5 text-orange-700">{item.blockers[0]}</div> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileHumanCapacity({ item }: { item: SpaceHumanCapacityItem }) {
|
||||
const tone = capacityTone(item.status);
|
||||
const utilization = item.effectiveMinutes > 0 ? Math.min(100, Math.round((item.plannedMinutes / item.effectiveMinutes) * 100)) : 0;
|
||||
return (
|
||||
<Link to={item.href} className="block rounded-[18px] border border-[#dbe5f2] bg-white p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl", tone.iconClass)}>
|
||||
<Clock3 className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{item.projectTitle}</span>
|
||||
<span className={cn("shrink-0 rounded-md px-2 py-1 text-xs font-semibold", tone.badgeClass)}>{tone.label}</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">
|
||||
已计划 {item.plannedMinutes}min · 剩余 {item.remainingMinutes}min · {utilization}% 使用
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-slate-500">主负载:{workTypeLabel(item.bottleneckType)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.recommendations.length ? <div className="mt-3 rounded-xl bg-[#eef4ff] px-3 py-2 text-xs leading-5 text-[#1d4ed8]">{item.recommendations[0]}</div> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileReleaseAttention({ item }: { item: SpaceReleaseAttentionItem }) {
|
||||
const tone = releaseAttentionTone(item);
|
||||
return (
|
||||
|
|
@ -1079,6 +1413,54 @@ function releaseGateLabel(status: SpaceReleaseAttentionItem["gateStatus"]): stri
|
|||
return labels[status];
|
||||
}
|
||||
|
||||
function readinessTone(status: SpaceExecutionReadinessItem["status"]): { label: string; iconClass: string; badgeClass: string } {
|
||||
if (status === "ready") {
|
||||
return { label: "可执行", iconClass: "bg-emerald-50 text-emerald-600", badgeClass: "bg-emerald-50 text-emerald-600" };
|
||||
}
|
||||
if (status === "blocked") {
|
||||
return { label: "阻塞", iconClass: "bg-rose-50 text-rose-600", badgeClass: "bg-rose-50 text-rose-600" };
|
||||
}
|
||||
if (status === "partial") {
|
||||
return { label: "待补齐", iconClass: "bg-orange-50 text-orange-600", badgeClass: "bg-orange-50 text-orange-600" };
|
||||
}
|
||||
return { label: "数据不足", iconClass: "bg-slate-100 text-slate-500", badgeClass: "bg-slate-100 text-slate-500" };
|
||||
}
|
||||
|
||||
function capacityTone(status: SpaceHumanCapacityItem["status"]): { label: string; iconClass: string; badgeClass: string; barClass: string } {
|
||||
if (status === "overloaded" || status === "blocked") {
|
||||
return { label: status === "blocked" ? "已锁定" : "超载", iconClass: "bg-rose-50 text-rose-600", badgeClass: "bg-rose-50 text-rose-600", barClass: "bg-rose-500" };
|
||||
}
|
||||
if (status === "near_full" || status === "attention") {
|
||||
return { label: status === "near_full" ? "接近满载" : "需关注", iconClass: "bg-orange-50 text-orange-600", badgeClass: "bg-orange-50 text-orange-600", barClass: "bg-orange-500" };
|
||||
}
|
||||
if (status === "manually_extended") {
|
||||
return { label: "已调整", iconClass: "bg-[#eef4ff] text-[#2463EB]", badgeClass: "bg-[#eef4ff] text-[#2463EB]", barClass: "bg-[#2463EB]" };
|
||||
}
|
||||
return { label: "可承接", iconClass: "bg-emerald-50 text-emerald-600", badgeClass: "bg-emerald-50 text-emerald-600", barClass: "bg-emerald-500" };
|
||||
}
|
||||
|
||||
function workTypeLabel(type?: SpaceHumanCapacityItem["bottleneckType"]): string {
|
||||
if (type === "focus") return "深度判断";
|
||||
if (type === "review") return "审查";
|
||||
if (type === "acceptance") return "验收";
|
||||
if (type === "coordination") return "协调";
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function capacityWidthClass(value: number): string {
|
||||
if (value >= 95) return "w-full";
|
||||
if (value >= 80) return "w-5/6";
|
||||
if (value >= 65) return "w-2/3";
|
||||
if (value >= 50) return "w-1/2";
|
||||
if (value >= 35) return "w-1/3";
|
||||
if (value > 0) return "w-1/4";
|
||||
return "w-0";
|
||||
}
|
||||
|
||||
function releaseAttentionTone(item: SpaceReleaseAttentionItem): { iconClass: string; badgeClass: string } {
|
||||
if (item.severity === "high") {
|
||||
return {
|
||||
|
|
@ -1155,10 +1537,12 @@ function NextActionIcon({ kind }: { kind: SpaceNextActionKind }) {
|
|||
if (kind === "release_blocked") return <AlertCircle className="h-5 w-5" />;
|
||||
if (kind === "execution_attention") return <Sparkles className="h-5 w-5" />;
|
||||
if (kind === "iteration_draft") return <FileText className="h-5 w-5" />;
|
||||
if (kind === "legacy_manual_gate") return <AlertCircle className="h-5 w-5" />;
|
||||
return <MessageCircle className="h-5 w-5" />;
|
||||
}
|
||||
|
||||
function nextActionCta(item: SpaceNextActionQueueItem): string {
|
||||
if (item.kind === "legacy_manual_gate") return item.categoryLabel === "补项目归属" ? "补归属" : "去处理";
|
||||
if (item.kind === "review_attention") return item.href.startsWith("/team-layer") ? "看审查" : "去审查";
|
||||
if (item.href.startsWith("/change-review")) return "去确认";
|
||||
if (item.href.startsWith("/execution-bridge")) return "看执行";
|
||||
|
|
|
|||
|
|
@ -53,53 +53,59 @@ export function SpaceSidebar({
|
|||
metrics?: Partial<Record<"recentProjects" | "savedIdeas" | "drafts" | "pendingConfirmations" | "aiProgress", number>>;
|
||||
}) {
|
||||
return (
|
||||
<aside className="flex w-[244px] shrink-0 flex-col border-r border-[#e6edf7] bg-[#fbfcff] px-5 py-5">
|
||||
<PMFlowBrandHeader />
|
||||
<nav className="mt-8 space-y-2">
|
||||
{sidebarMain.map((item) => {
|
||||
const badge = item.metricKey ? metrics?.[item.metricKey] : undefined;
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-[244px] shrink-0 flex-col border-r border-[#e6edf7] bg-[#fbfcff] px-5 py-5 [@media(max-height:820px)]:py-4">
|
||||
<div className="shrink-0">
|
||||
<PMFlowBrandHeader />
|
||||
</div>
|
||||
<div className="mt-8 min-h-0 flex-1 overflow-y-auto pr-1 [@media(max-height:820px)]:mt-5 [@media(max-height:820px)]:pr-0">
|
||||
<nav className="space-y-2 [@media(max-height:820px)]:space-y-1">
|
||||
{sidebarMain.map((item) => {
|
||||
const badge = item.metricKey ? metrics?.[item.metricKey] : undefined;
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center justify-between rounded-xl px-4 text-sm font-medium transition [@media(max-height:820px)]:h-9",
|
||||
item.label === activeLabel ? "bg-[#eef4ff] text-[#2463EB]" : "text-slate-600 hover:bg-white hover:text-slate-950"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
{typeof badge === "number" && badge > 0 ? <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{badge}</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="my-7 h-px bg-[#e6edf7] [@media(max-height:820px)]:my-3" />
|
||||
<nav className="space-y-2 pb-4 [@media(max-height:820px)]:space-y-1 [@media(max-height:820px)]:pb-2">
|
||||
{sidebarTools.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center justify-between rounded-xl px-4 text-sm font-medium transition",
|
||||
item.label === activeLabel ? "bg-[#eef4ff] text-[#2463EB]" : "text-slate-600 hover:bg-white hover:text-slate-950"
|
||||
)}
|
||||
className="flex h-10 w-full items-center gap-3 rounded-xl px-4 text-sm font-medium text-slate-600 transition hover:bg-white hover:text-slate-950 [@media(max-height:820px)]:h-9"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
{typeof badge === "number" && badge > 0 ? <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{badge}</span> : null}
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||
<nav className="space-y-2">
|
||||
{sidebarTools.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex h-10 w-full items-center gap-3 rounded-xl px-4 text-sm font-medium text-slate-600 transition hover:bg-white hover:text-slate-950"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto rounded-xl border border-[#e6edf7] bg-white p-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>空间用量</span>
|
||||
<button type="button" className="text-xs text-[#2463EB]">升级</button>
|
||||
</div>
|
||||
<UsageLine label="文档与附件" value="12.4 GB / 50 GB" percent="w-[25%]" />
|
||||
<UsageLine label="AI 额度(本月)" value="32.1k / 100k" percent="w-[32%]" />
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-emerald-600">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
已同步 · 刚刚
|
||||
<div className="shrink-0 pt-4 [@media(max-height:820px)]:pt-3">
|
||||
<div className="rounded-xl border border-[#e6edf7] bg-white p-4 [@media(max-height:820px)]:p-3">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>空间用量</span>
|
||||
<button type="button" className="text-xs text-[#2463EB]">升级</button>
|
||||
</div>
|
||||
<UsageLine label="文档与附件" value="12.4 GB / 50 GB" percent="w-[25%]" />
|
||||
<UsageLine label="AI 额度(本月)" value="32.1k / 100k" percent="w-[32%]" />
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-emerald-600 [@media(max-height:820px)]:mt-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
已同步 · 刚刚
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
@ -134,10 +140,10 @@ export function SpaceTopBar({ newProjectTo = "/project-launch" }: { newProjectTo
|
|||
|
||||
export function UsageLine({ label, value, percent }: { label: string; value: string; percent: string }) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mt-4 [@media(max-height:820px)]:mt-2">
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-xs font-medium text-slate-600">{value}</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-slate-100">
|
||||
<div className="mt-2 h-1.5 rounded-full bg-slate-100 [@media(max-height:820px)]:mt-1">
|
||||
<div className={cn("h-full rounded-full bg-[#2463EB]", percent)} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export function formatStageLabel(stage: PMFlowStage | string) {
|
|||
growth: "增长",
|
||||
"review gate": "变更审查",
|
||||
"execution bridge": "执行桥",
|
||||
"team layer": "团队协作层",
|
||||
"team layer": "团队层",
|
||||
};
|
||||
return labelMap[stage] || stage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
|
|
@ -14,13 +15,36 @@ import {
|
|||
Send,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
TimerReset,
|
||||
TrendingUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
createPMFlowCapacityOverride,
|
||||
getPMFlowCapacityOverrides,
|
||||
getPMFlowCapacityPlan,
|
||||
getPMFlowExecutionCapabilities,
|
||||
simulatePMFlowCapacityOverride,
|
||||
} from "@/api/pmflowOs";
|
||||
import { TeamFeedPanel } from "@/features/pmflow-os/components/TeamFeedPanel";
|
||||
import { usePMFlowOsStore } from "@/features/pmflow-os/store";
|
||||
import type { PMFlowReviewerRouting, PMFlowReviewerRoutingItem, PMFlowRiskLevel } from "@/features/pmflow-os/types";
|
||||
import type {
|
||||
PMFlowCapacityOverridePayload,
|
||||
PMFlowCapacityOverrideRecord,
|
||||
PMFlowCapacityOverrideSimulation,
|
||||
PMFlowDailyCapacityPlan,
|
||||
PMFlowExecutionCapabilitySnapshot,
|
||||
PMFlowExecutionCapabilityStatus,
|
||||
PMFlowHumanWorkType,
|
||||
PMFlowMemberCapacityPlan,
|
||||
PMFlowReviewerRouting,
|
||||
PMFlowReviewerRoutingItem,
|
||||
PMFlowRiskLevel,
|
||||
PMFlowTeamExecutionSignal,
|
||||
} from "@/features/pmflow-os/types";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const cn = (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(" ");
|
||||
|
||||
|
|
@ -55,10 +79,40 @@ const agentCards = [
|
|||
|
||||
export default function TeamLayerPage() {
|
||||
const navigate = useNavigate();
|
||||
const { state, openReview, projectionWarnings } = usePMFlowOsStore();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useAuthStore((store) => store.user);
|
||||
const { state, openReview, projectionWarnings, dataSourceMode } = usePMFlowOsStore();
|
||||
const reviewerRouting = state.reviewerRouting || emptyReviewerRouting();
|
||||
const pendingReviewCount = state.changeItems.filter((item) => item.status === "pending_review").length;
|
||||
const reviewerWarnings = projectionWarnings.filter((warning) => warning.includes("reviewer_routing_warning"));
|
||||
const capabilityQuery = useQuery({
|
||||
queryKey: ["pmflow-os", "execution-capabilities", state.project.id],
|
||||
queryFn: () => getPMFlowExecutionCapabilities(state.project.id),
|
||||
enabled: dataSourceMode === "api" && Boolean(state.project.id) && state.project.id !== "unselected-project",
|
||||
});
|
||||
const capacityQuery = useQuery({
|
||||
queryKey: ["pmflow-os", "capacity-plan", state.project.id],
|
||||
queryFn: () => getPMFlowCapacityPlan(state.project.id),
|
||||
enabled: dataSourceMode === "api" && Boolean(state.project.id) && state.project.id !== "unselected-project",
|
||||
});
|
||||
const overridesQuery = useQuery({
|
||||
queryKey: ["pmflow-os", "capacity-overrides", state.project.id],
|
||||
queryFn: () => getPMFlowCapacityOverrides(state.project.id),
|
||||
enabled: dataSourceMode === "api" && Boolean(state.project.id) && state.project.id !== "unselected-project",
|
||||
});
|
||||
const canManageCapacity = ["OWNER", "REVIEWER"].includes(String(currentUser?.role || "").toUpperCase());
|
||||
const invalidateCapacity = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["pmflow-os", "capacity-plan", state.project.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["pmflow-os", "capacity-overrides", state.project.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["pmflow-os", "execution-capabilities", state.project.id] });
|
||||
};
|
||||
const simulateMutation = useMutation({
|
||||
mutationFn: (payload: PMFlowCapacityOverridePayload) => simulatePMFlowCapacityOverride(state.project.id, payload),
|
||||
});
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: (payload: PMFlowCapacityOverridePayload) => createPMFlowCapacityOverride(state.project.id, payload),
|
||||
onSuccess: invalidateCapacity,
|
||||
});
|
||||
const handleOpenReview = (item: PMFlowReviewerRoutingItem) => {
|
||||
openReview(item.changeSetId);
|
||||
navigate(item.targetUrl || `/change-review?projectId=${item.projectId}&changeSetId=${item.changeSetId}`);
|
||||
|
|
@ -74,8 +128,27 @@ export default function TeamLayerPage() {
|
|||
warnings={reviewerWarnings}
|
||||
onOpenReview={handleOpenReview}
|
||||
/>
|
||||
<ExecutionCapabilityPanel
|
||||
snapshot={capabilityQuery.data}
|
||||
isLoading={capabilityQuery.isLoading}
|
||||
error={capabilityQuery.error}
|
||||
projectId={state.project.id}
|
||||
/>
|
||||
<TeamCapacityPanel
|
||||
plan={capacityQuery.data ?? capabilityQuery.data?.adaptiveCapacitySummary}
|
||||
overrides={overridesQuery.data ?? []}
|
||||
isLoading={capacityQuery.isLoading}
|
||||
error={capacityQuery.error}
|
||||
canManage={canManageCapacity}
|
||||
simulation={simulateMutation.data}
|
||||
isSimulating={simulateMutation.isPending}
|
||||
isApplying={applyMutation.isPending}
|
||||
simulateError={simulateMutation.error}
|
||||
applyError={applyMutation.error}
|
||||
onSimulate={(payload) => simulateMutation.mutate(payload)}
|
||||
onApply={(payload) => applyMutation.mutate(payload)}
|
||||
/>
|
||||
<TeamFeedPanel feed={state.feed} inbox={state.teamInbox} />
|
||||
<AgentWorkstationPanel />
|
||||
</main>
|
||||
<div className="hidden min-[1440px]:block">
|
||||
<AICopilotPanel />
|
||||
|
|
@ -215,6 +288,432 @@ function MetricPill({ label, value }: { label: string; value: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TeamCapacityPanel({
|
||||
plan,
|
||||
overrides,
|
||||
isLoading,
|
||||
error,
|
||||
canManage,
|
||||
simulation,
|
||||
isSimulating,
|
||||
isApplying,
|
||||
simulateError,
|
||||
applyError,
|
||||
onSimulate,
|
||||
onApply,
|
||||
}: {
|
||||
plan?: PMFlowDailyCapacityPlan | null;
|
||||
overrides: PMFlowCapacityOverrideRecord[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
canManage: boolean;
|
||||
simulation?: PMFlowCapacityOverrideSimulation;
|
||||
isSimulating: boolean;
|
||||
isApplying: boolean;
|
||||
simulateError: unknown;
|
||||
applyError: unknown;
|
||||
onSimulate: (payload: PMFlowCapacityOverridePayload) => void;
|
||||
onApply: (payload: PMFlowCapacityOverridePayload) => void;
|
||||
}) {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [addedMinutes, setAddedMinutes] = useState("60");
|
||||
const [maxUtilization, setMaxUtilization] = useState("0.95");
|
||||
const [overtimeType, setOvertimeType] = useState<"general" | "focus" | "review" | "release_guard">("general");
|
||||
const [reason, setReason] = useState("");
|
||||
const selectedMember = useMemo(
|
||||
() => plan?.memberPlans.find((member) => member.userId === selectedUserId) ?? null,
|
||||
[plan?.memberPlans, selectedUserId]
|
||||
);
|
||||
const payload = selectedMember
|
||||
? {
|
||||
userId: selectedMember.userId,
|
||||
date: plan?.date,
|
||||
overrideType: "overtime" as const,
|
||||
addedMinutes: Number(addedMinutes || 0),
|
||||
overtimeType,
|
||||
maxPlannedUtilization: Number(maxUtilization || 0.85),
|
||||
reason: reason.trim(),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#2463EB]">
|
||||
<TimerReset className="h-4 w-4" />
|
||||
自适应人类容量
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-semibold text-slate-800">团队今日容量调度台</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
时间是硬上限,任务类型是弹性的;系统按当天任务池动态生成基线,并用风险、疲劳、上下文和工具准备度判断是否还能承接。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<MetricPill label="有效容量" value={plan ? `${plan.teamEffectiveMinutes}m` : "未同步"} />
|
||||
<MetricPill label="人工触点" value={plan ? `${plan.teamPlannedTouchMinutes.total}m` : "未同步"} />
|
||||
<MetricPill label="成员" value={String(plan?.memberPlans.length ?? 0)} />
|
||||
<MetricPill label="调整记录" value={String(overrides.length)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-5 text-sm text-slate-500">正在生成今日容量计划…</div>
|
||||
) : error ? (
|
||||
<div className="mt-5 rounded-xl border border-orange-200 bg-orange-50 px-4 py-4 text-sm leading-6 text-orange-700">
|
||||
今日容量计划暂时不可用;系统不会回退到固定工时比例或静态技能标签。
|
||||
</div>
|
||||
) : plan ? (
|
||||
<>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-400">团队需求结构</div>
|
||||
<div className="mt-1 text-sm font-semibold text-slate-800">
|
||||
深度判断 {percent(plan.teamDemandMix.focus)} · 审查 {percent(plan.teamDemandMix.review)} · 验收 {percent(plan.teamDemandMix.acceptance)} · 协调 {percent(plan.teamDemandMix.coordination)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-lg bg-[#eef4ff] px-2.5 py-1 text-xs font-semibold text-[#2463EB]">{plan.date}</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plan.memberPlans.length ? (
|
||||
plan.memberPlans.map((member) => (
|
||||
<MemberCapacityCard
|
||||
key={member.userId}
|
||||
member={member}
|
||||
canManage={canManage}
|
||||
isSelected={selectedUserId === member.userId}
|
||||
onSelect={() => setSelectedUserId(member.userId)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-[#e2e9f5] bg-white px-4 py-5 text-sm leading-6 text-slate-500">
|
||||
当前项目没有可计算容量的成员。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] p-4">
|
||||
<div className="text-sm font-semibold text-slate-800">AI 预处理与负责人调整</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{plan.recommendations.slice(0, 3).map((item) => (
|
||||
<div key={item} className="rounded-lg border border-[#d9e6ff] bg-white px-3 py-2 text-xs leading-5 text-[#1d4ed8]">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-[#e2e9f5] bg-white p-3">
|
||||
<div className="text-xs font-semibold text-slate-400">调整前必须先模拟</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-[#dbe5f2] bg-white px-3 text-sm text-slate-800 outline-none focus:border-[#2463EB]"
|
||||
value={selectedUserId ?? ""}
|
||||
onChange={(event) => setSelectedUserId(event.target.value || null)}
|
||||
>
|
||||
<option value="">选择成员</option>
|
||||
{plan.memberPlans.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input className="h-10 rounded-lg border border-[#dbe5f2] px-3 text-sm outline-none focus:border-[#2463EB]" value={addedMinutes} onChange={(event) => setAddedMinutes(event.target.value)} placeholder="增加分钟" />
|
||||
<input className="h-10 rounded-lg border border-[#dbe5f2] px-3 text-sm outline-none focus:border-[#2463EB]" value={maxUtilization} onChange={(event) => setMaxUtilization(event.target.value)} placeholder="最大利用率" />
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-[#dbe5f2] bg-white px-3 text-sm text-slate-800 outline-none focus:border-[#2463EB]"
|
||||
value={overtimeType}
|
||||
onChange={(event) => setOvertimeType(event.target.value as typeof overtimeType)}
|
||||
>
|
||||
<option value="general">通用加班</option>
|
||||
<option value="focus">深度判断</option>
|
||||
<option value="review">审查</option>
|
||||
<option value="release_guard">发布守门</option>
|
||||
</select>
|
||||
<textarea
|
||||
className="min-h-[76px] w-full rounded-lg border border-[#dbe5f2] px-3 py-2 text-sm outline-none focus:border-[#2463EB]"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="填写原因,写入审计记录"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-lg border border-[#cfe0ff] px-3 py-2 text-sm font-semibold text-[#2463EB] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!payload || !payload.reason || isSimulating}
|
||||
onClick={() => payload && onSimulate(payload)}
|
||||
>
|
||||
{isSimulating ? "模拟中" : "模拟结果"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-lg bg-[#2463EB] px-3 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!payload || !payload.reason || !simulation || simulation.afterPlan.userId !== payload.userId || isApplying}
|
||||
onClick={() => payload && onApply(payload)}
|
||||
>
|
||||
{isApplying ? "应用中" : "确认调整"}
|
||||
</button>
|
||||
</div>
|
||||
{simulateError || applyError ? (
|
||||
<div className="rounded-lg bg-rose-50 px-3 py-2 text-xs leading-5 text-rose-700">
|
||||
{capacityMutationErrorMessage(simulateError || applyError)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl border border-[#e2e9f5] bg-white px-3 py-3 text-xs leading-5 text-slate-500">
|
||||
普通成员可查看容量解释;容量调整、加班和例外需要 OWNER / REVIEWER 执行。
|
||||
</div>
|
||||
)}
|
||||
{simulation ? <CapacitySimulationResult simulation={simulation} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{overrides.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-slate-800">成员 #{item.userId} · {overrideTypeLabel(item.overrideType)}</span>
|
||||
<span className="text-xs text-slate-400">到期 {formatShortDate(item.expiresAt)}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-500">{item.reason}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-5 text-sm leading-6 text-slate-500">
|
||||
尚无真实容量计划。当前入口不会用 mock 或传统 workload ratio 冒充。
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCapacityCard({
|
||||
member,
|
||||
canManage,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
member: PMFlowMemberCapacityPlan;
|
||||
canManage: boolean;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const tone = capacityStatusTone(member.capacityStatus);
|
||||
const utilization = member.effectiveMinutes > 0 ? Math.round((member.plannedTouchMinutes.total / member.effectiveMinutes) * 100) : 0;
|
||||
return (
|
||||
<article className={cn("rounded-xl border bg-white p-4", isSelected ? "border-[#2463EB]" : "border-[#e2e9f5]")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-900">{member.displayName}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{member.role} · 主负载 {humanWorkTypeLabel(member.bottleneckType)}</div>
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold", tone.badge)}>{tone.label}</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<CapabilityMiniStat label="有效" value={`${member.effectiveMinutes}m`} />
|
||||
<CapabilityMiniStat label="已排" value={`${member.plannedTouchMinutes.total}m`} />
|
||||
<CapabilityMiniStat label="剩余" value={`${member.remainingMinutes}m`} />
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">
|
||||
动态基线:判断 {percent(member.suggestedBaseline.focus)} · 审查 {percent(member.suggestedBaseline.review)} · 验收 {percent(member.suggestedBaseline.acceptance)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-slate-500">当前使用率 {utilization}%;疲劳风险不会因加班自动消失。</div>
|
||||
{member.explanations[0] ? <div className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">{member.explanations[0]}</div> : null}
|
||||
{canManage ? (
|
||||
<button type="button" className="mt-3 rounded-lg border border-[#cfe0ff] px-3 py-1.5 text-xs font-semibold text-[#2463EB]" onClick={onSelect}>
|
||||
调整容量
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CapacitySimulationResult({ simulation }: { simulation: PMFlowCapacityOverrideSimulation }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-[#d9e6ff] bg-white p-3">
|
||||
<div className="text-xs font-semibold text-slate-400">模拟结果</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||
<CapabilityMiniStat label="调整前剩余" value={`${simulation.beforePlan.remainingMinutes}m`} />
|
||||
<CapabilityMiniStat label="调整后剩余" value={`${simulation.afterPlan.remainingMinutes}m`} />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-xs leading-5 text-slate-500">
|
||||
{simulation.impactSummary.map((item) => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionCapabilityPanel({
|
||||
snapshot,
|
||||
isLoading,
|
||||
error,
|
||||
projectId,
|
||||
}: {
|
||||
snapshot?: PMFlowExecutionCapabilitySnapshot;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
projectId: string;
|
||||
}) {
|
||||
const visibleSignals = snapshot?.teamSignals.slice(0, 4) ?? [];
|
||||
const blockers = snapshot?.warnings.filter((warning) =>
|
||||
warning.includes("missing") || warning.includes("empty") || warning.includes("缺") || warning.includes("reviewer")
|
||||
).slice(0, 3) ?? [];
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#2463EB]">
|
||||
<BriefcaseBusiness className="h-4 w-4" />
|
||||
执行能力图谱
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-semibold text-slate-800">执行能力与容量信号</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
这里不再按“谁会某种开发语言”分配任务,而是聚合 AI 执行包、工具准备度、人类判断责任、审查责任和容量信号。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<MetricPill label="成员/执行单元" value={String(snapshot?.teamSignals.length ?? 0)} />
|
||||
<MetricPill label="可执行模式" value={String(snapshot?.executionModes.filter((item) => item.status === "ready").length ?? 0)} />
|
||||
<MetricPill label="阻塞提示" value={String(blockers.length)} />
|
||||
<MetricPill label="工具准备" value={snapshot?.personalReadiness.status === "ready" ? "可执行" : "待补齐"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-5 text-sm text-slate-500">正在读取执行能力快照…</div>
|
||||
) : error ? (
|
||||
<div className="mt-5 rounded-xl border border-orange-200 bg-orange-50 px-4 py-4 text-sm leading-6 text-orange-700">
|
||||
执行能力快照暂时不可用;Team Layer 会继续保留审查路由和真实团队动态,不用静态技能表冒充。
|
||||
</div>
|
||||
) : snapshot ? (
|
||||
<>
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-400">个人执行准备度</div>
|
||||
<div className="mt-1 text-base font-semibold text-slate-800">{snapshot.personalReadiness.recommendedNextStep}</div>
|
||||
</div>
|
||||
<span className={cn("rounded-lg px-2.5 py-1 text-xs font-semibold", capabilityStatusTone(snapshot.personalReadiness.status).badge)}>
|
||||
{capabilityStatusTone(snapshot.personalReadiness.status).label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<CapabilityMiniStat label="待确认" value={snapshot.personalReadiness.waitingForUser.pendingConfirmations || 0} />
|
||||
<CapabilityMiniStat label="待审查" value={snapshot.personalReadiness.waitingForUser.pendingReviews || 0} />
|
||||
<CapabilityMiniStat label="待验收" value={snapshot.personalReadiness.waitingForUser.waitingAcceptance || 0} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{snapshot.personalReadiness.toolReadiness.map((tool) => (
|
||||
<div key={tool.key} className="rounded-lg border border-[#e2e9f5] bg-white px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-700">{tool.label}</span>
|
||||
<span className={cn("rounded-md px-2 py-0.5 text-xs font-semibold", capabilityStatusTone(tool.status).badge)}>
|
||||
{capabilityStatusTone(tool.status).label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">{tool.summary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] p-4">
|
||||
<div className="text-xs font-semibold text-slate-400">执行模式判断</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{snapshot.executionModes.map((mode) => (
|
||||
<div key={mode.mode} className="rounded-lg border border-[#e2e9f5] bg-white px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{mode.label}</span>
|
||||
<span className={cn("rounded-md px-2 py-0.5 text-xs font-semibold", capabilityStatusTone(mode.status).badge)}>
|
||||
{capabilityStatusTone(mode.status).label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{mode.reason}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blockers.length ? (
|
||||
<div className="mt-4 rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm leading-6 text-orange-700">
|
||||
{blockers[0]}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-4">
|
||||
{visibleSignals.length ? (
|
||||
visibleSignals.map((signal) => <TeamCapabilityCard key={signal.userId} signal={signal} />)
|
||||
) : (
|
||||
<div className="xl:col-span-4 rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-5 text-sm leading-6 text-slate-500">
|
||||
当前项目没有可展示的成员能力信号。后续团队空间只能扩展此模型,不回退到传统用户/技能/工时报表。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-5 text-sm leading-6 text-slate-500">
|
||||
URL 缺少真实 projectId 时不读取执行能力快照。当前项目:{projectId}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCapabilityCard({ signal }: { signal: PMFlowTeamExecutionSignal }) {
|
||||
const capacityTone = capabilityStatusTone(signal.capacity.status);
|
||||
return (
|
||||
<article className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-slate-800">{signal.displayName}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-400">{signal.role}</p>
|
||||
</div>
|
||||
<span className={cn("rounded-lg px-2 py-1 text-xs font-semibold", capacityTone.badge)}>{signal.capacity.label}</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<CapabilityMiniStat label="容量" value={`${Math.round(signal.capacity.workloadRatio)}%`} />
|
||||
<CapabilityMiniStat label="待审" value={signal.capacity.pendingReviewCount} />
|
||||
<CapabilityMiniStat label="执行" value={signal.capacity.activeExecutionCount} />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-xs leading-5 text-slate-500">
|
||||
<CapabilityLine label="判断责任" values={signal.judgementResponsibilities} />
|
||||
<CapabilityLine label="审查责任" values={signal.reviewResponsibilities} />
|
||||
<CapabilityLine label="工具准备" values={[signal.toolStatus.label]} />
|
||||
<CapabilityLine label="参考能力" values={signal.referenceSkills.length ? signal.referenceSkills : ["数据不足"]} />
|
||||
</div>
|
||||
{signal.warnings.length ? <div className="mt-3 rounded-lg bg-orange-50 px-3 py-2 text-xs leading-5 text-orange-700">{signal.warnings[0]}</div> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityMiniStat({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[#e2e9f5] bg-white px-2 py-2">
|
||||
<div className="text-[11px] font-semibold text-slate-400">{label}</div>
|
||||
<div className="mt-1 font-semibold text-slate-800">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityLine({ label, values }: { label: string; values: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="font-semibold text-slate-400">{label}:</span>
|
||||
<span>{values.slice(0, 3).join(" / ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamActivityPanel() {
|
||||
return (
|
||||
<Panel className="overflow-hidden" style={{ height: 640 }}>
|
||||
|
|
@ -458,6 +957,53 @@ function iconTone(tone: string) {
|
|||
return "bg-[#2463EB]";
|
||||
}
|
||||
|
||||
function capacityStatusTone(status: PMFlowMemberCapacityPlan["capacityStatus"]) {
|
||||
if (status === "overloaded") return { label: "超载", badge: "bg-rose-50 text-rose-600" };
|
||||
if (status === "blocked") return { label: "已锁定", badge: "bg-rose-50 text-rose-600" };
|
||||
if (status === "near_full") return { label: "接近满载", badge: "bg-orange-50 text-orange-600" };
|
||||
if (status === "attention") return { label: "需关注", badge: "bg-orange-50 text-orange-600" };
|
||||
if (status === "manually_extended") return { label: "已调整", badge: "bg-[#eef4ff] text-[#2463EB]" };
|
||||
return { label: "可承接", badge: "bg-emerald-50 text-emerald-600" };
|
||||
}
|
||||
|
||||
function percent(value: number) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function humanWorkTypeLabel(type?: PMFlowHumanWorkType | null) {
|
||||
if (type === "focus") return "深度判断";
|
||||
if (type === "review") return "审查";
|
||||
if (type === "acceptance") return "验收";
|
||||
if (type === "coordination") return "协调";
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
function overrideTypeLabel(type: PMFlowCapacityOverrideRecord["overrideType"]) {
|
||||
if (type === "overtime") return "加班容量";
|
||||
if (type === "extend_minutes") return "扩展可用时间";
|
||||
if (type === "lock_capacity") return "锁定容量";
|
||||
if (type === "work_type_bias") return "类型偏向";
|
||||
return "高风险例外";
|
||||
}
|
||||
|
||||
function formatShortDate(value?: string | null) {
|
||||
if (!value) return "未设置";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return `${date.getMonth() + 1}-${date.getDate()} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function capacityMutationErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "容量调整失败";
|
||||
}
|
||||
|
||||
function capabilityStatusTone(status: PMFlowExecutionCapabilityStatus) {
|
||||
if (status === "ready") return { label: "可执行", badge: "bg-emerald-50 text-emerald-600" };
|
||||
if (status === "blocked") return { label: "阻塞", badge: "bg-rose-50 text-rose-600" };
|
||||
if (status === "partial") return { label: "待补齐", badge: "bg-orange-50 text-orange-600" };
|
||||
return { label: "数据不足", badge: "bg-slate-100 text-slate-500" };
|
||||
}
|
||||
|
||||
function riskLabel(level: PMFlowRiskLevel) {
|
||||
if (level === "high") return "高风险";
|
||||
if (level === "medium") return "中风险";
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePMFlowOsStore } from "@/features/pmflow-os/store";
|
||||
import type { PMFlowObjectStatus, PMFlowStage, ProjectTruthState } from "@/features/pmflow-os/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const objectStats = [
|
||||
|
|
@ -81,6 +84,11 @@ const ganttRows: GanttGroup[] = [
|
|||
];
|
||||
|
||||
export default function WorkGraphPage() {
|
||||
const { state, dataSourceMode, projectionWarnings } = usePMFlowOsStore();
|
||||
if (dataSourceMode !== "demo") {
|
||||
return <RealWorkGraphPage state={state} projectionWarnings={projectionWarnings} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-white text-slate-950">
|
||||
<div className="grid min-h-[calc(100vh-150px)] gap-4 min-[1440px]:grid-cols-[minmax(0,1fr)_380px]">
|
||||
|
|
@ -155,6 +163,375 @@ export default function WorkGraphPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function RealWorkGraphPage({
|
||||
state,
|
||||
projectionWarnings,
|
||||
}: {
|
||||
state: ProjectTruthState;
|
||||
projectionWarnings: string[];
|
||||
}) {
|
||||
const pendingChanges = state.changeItems.filter((item) => item.status === "pending_review");
|
||||
const blockedTasks = state.tasks.filter((task) => task.status === "blocked");
|
||||
const activeTasks = state.tasks.filter((task) => task.status === "in_progress");
|
||||
const highRisks = state.risks.filter((risk) => risk.level === "high");
|
||||
const objectStats = [
|
||||
{ label: "需求", value: state.requirements.length, icon: FileText },
|
||||
{ label: "决策", value: state.decisions.length, icon: ClipboardList },
|
||||
{ label: "风险", value: state.risks.length, icon: AlertTriangle },
|
||||
{ label: "任务", value: state.tasks.length, icon: GitBranch },
|
||||
{ label: "模型决策", value: state.modelDecisions.length, icon: Bot },
|
||||
{ label: "测试记录", value: state.testRuns.length, icon: FlaskConical },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-white pb-6 text-slate-950">
|
||||
<div className="grid gap-4 min-[1440px]:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<main className="min-w-0 space-y-4">
|
||||
<section className="rounded-[20px] border border-[#dfe8f7] bg-white p-5 shadow-[0_10px_24px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-400">PROJECT WORK GRAPH</div>
|
||||
<h1 className="mt-2 truncate text-2xl font-semibold tracking-[-0.04em] text-slate-950">{state.project.name}</h1>
|
||||
<p className="mt-2 max-w-4xl text-sm leading-6 text-slate-500">{state.project.goal}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Link to={`/pmflow-os?projectId=${state.project.id}`} className="inline-flex h-10 items-center rounded-xl bg-[#2463EB] px-4 text-sm font-semibold text-white hover:bg-[#1f58d5]">
|
||||
回主流程
|
||||
</Link>
|
||||
<Link to={`/change-review?projectId=${state.project.id}`} className="inline-flex h-10 items-center rounded-xl border border-[#dbe6ff] bg-white px-4 text-sm font-semibold text-[#2463EB] hover:bg-[#f5f8ff]">
|
||||
待确认 {pendingChanges.length}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<RealStageRail activeStage={state.project.stage} />
|
||||
</section>
|
||||
|
||||
{projectionWarnings.length ? (
|
||||
<section className="rounded-[16px] border border-orange-100 bg-orange-50 px-4 py-3 text-sm font-medium text-orange-700">
|
||||
{projectionWarnings.slice(0, 2).join(";")}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
|
||||
{objectStats.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.label} className="rounded-[16px] border border-[#e6edf7] bg-[#fbfdff] px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-slate-600">{item.label}</span>
|
||||
<Icon className="h-4 w-4 text-[#2463EB]" />
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-slate-950">{item.value}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-3">
|
||||
<RealObjectColumn
|
||||
icon={FileText}
|
||||
title="需求"
|
||||
items={state.requirements}
|
||||
emptyTitle="暂无已确认需求"
|
||||
meta={(item) => `${stageLabel(item.stage)} · ${statusLabel(item.status)} · 关联任务 ${item.linkedTaskIds.length}`}
|
||||
tone={(item) => statusToneKey(item.status)}
|
||||
/>
|
||||
<RealObjectColumn
|
||||
icon={Target}
|
||||
title="决策"
|
||||
items={state.decisions}
|
||||
emptyTitle="暂无已确认决策"
|
||||
meta={(item) => `${stageLabel(item.stage)} · ${statusLabel(item.status)} · ${item.owner || "未指定负责人"}`}
|
||||
tone={(item) => statusToneKey(item.status)}
|
||||
/>
|
||||
<RealObjectColumn
|
||||
icon={AlertTriangle}
|
||||
title="风险"
|
||||
items={state.risks}
|
||||
emptyTitle="暂无风险对象"
|
||||
meta={(item) => `${stageLabel(item.stage)} · ${statusLabel(item.status)} · ${riskLabel(item.level)}`}
|
||||
tone={(item) => (item.level === "high" ? "danger" : item.level === "medium" ? "warning" : statusToneKey(item.status))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<RealTaskPanel tasks={state.tasks} />
|
||||
<RealDecisionSupportPanel state={state} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<RealNextActionPanel
|
||||
projectId={state.project.id}
|
||||
pendingChanges={pendingChanges.length}
|
||||
activeTasks={activeTasks.length}
|
||||
blockedTasks={blockedTasks.length}
|
||||
highRisks={highRisks.length}
|
||||
/>
|
||||
<RealEvidencePanel evidence={state.evidence} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RealStageRail({ activeStage }: { activeStage: PMFlowStage }) {
|
||||
const ordered: Array<{ key: PMFlowStage; label: string }> = [
|
||||
{ key: "idea", label: "创意" },
|
||||
{ key: "validation", label: "验证" },
|
||||
{ key: "planning", label: "规划" },
|
||||
{ key: "development", label: "开发" },
|
||||
{ key: "testing", label: "测试" },
|
||||
{ key: "release", label: "发布" },
|
||||
{ key: "growth", label: "增长" },
|
||||
];
|
||||
const activeIndex = Math.max(0, ordered.findIndex((item) => item.key === activeStage));
|
||||
return (
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-7">
|
||||
{ordered.map((stage, index) => {
|
||||
const active = stage.key === activeStage;
|
||||
const done = index < activeIndex;
|
||||
return (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-3",
|
||||
active ? "border-[#2463EB] bg-[#f5f8ff]" : done ? "border-emerald-100 bg-emerald-50" : "border-[#e6edf7] bg-white"
|
||||
)}
|
||||
>
|
||||
<div className={cn("text-sm font-semibold", active ? "text-[#2463EB]" : done ? "text-emerald-600" : "text-slate-500")}>
|
||||
{stage.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{active ? "当前阶段" : done ? "已推进" : "待推进"}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RealObjectColumn<T extends { id: string; title: string; summary: string }>({
|
||||
icon: Icon,
|
||||
title,
|
||||
items,
|
||||
emptyTitle,
|
||||
meta,
|
||||
tone,
|
||||
}: {
|
||||
icon: typeof FileText;
|
||||
title: string;
|
||||
items: T[];
|
||||
emptyTitle: string;
|
||||
meta: (item: T) => string;
|
||||
tone: (item: T) => "brand" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-[18px] border border-[#e0e8f5] bg-white px-5 py-5 shadow-[0_8px_18px_rgba(15,23,42,0.035)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="flex min-w-0 items-center gap-2 text-base font-semibold text-slate-900">
|
||||
<Icon className="h-5 w-5 shrink-0 text-[#2463EB]" />
|
||||
{title}
|
||||
<span className="text-sm text-slate-400">{items.length}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4 divide-y divide-[#edf2fb]">
|
||||
{items.length ? (
|
||||
items.slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-850">{item.title}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{item.summary}</div>
|
||||
<div className="mt-2 text-xs text-slate-400">{meta(item)}</div>
|
||||
</div>
|
||||
<ToneBadge tone={tone(item)}>{title}</ToneBadge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-[#dbe5f2] bg-[#fbfdff] px-4 py-6 text-sm text-slate-500">{emptyTitle}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RealTaskPanel({ tasks }: { tasks: ProjectTruthState["tasks"] }) {
|
||||
const lanes: Array<{ key: PMFlowObjectStatus; label: string }> = [
|
||||
{ key: "draft", label: "待确认" },
|
||||
{ key: "confirmed", label: "已确认" },
|
||||
{ key: "in_progress", label: "执行中" },
|
||||
{ key: "blocked", label: "阻塞" },
|
||||
{ key: "done", label: "完成" },
|
||||
];
|
||||
return (
|
||||
<section className="rounded-[18px] border border-[#e0e8f5] bg-white p-5 shadow-[0_8px_18px_rgba(15,23,42,0.035)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-900">任务对象</h2>
|
||||
<span className="text-sm font-semibold text-slate-400">{tasks.length} 项</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 xl:grid-cols-5">
|
||||
{lanes.map((lane) => {
|
||||
const laneTasks = tasks.filter((task) => task.status === lane.key);
|
||||
return (
|
||||
<div key={lane.key} className="rounded-xl border border-[#edf2fb] bg-[#fbfdff] p-3">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
|
||||
<span>{lane.label}</span>
|
||||
<span>{laneTasks.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{laneTasks.slice(0, 3).map((task) => (
|
||||
<div key={task.id} className="rounded-lg border border-[#e6edf7] bg-white px-3 py-2">
|
||||
<div className="line-clamp-2 text-xs font-semibold leading-5 text-slate-800">{task.title}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">{task.assignee || "未分配"} · {stageLabel(task.stage)}</div>
|
||||
</div>
|
||||
))}
|
||||
{!laneTasks.length ? <div className="text-xs text-slate-400">暂无</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RealDecisionSupportPanel({ state }: { state: ProjectTruthState }) {
|
||||
return (
|
||||
<section className="rounded-[18px] border border-[#e0e8f5] bg-white p-5 shadow-[0_8px_18px_rgba(15,23,42,0.035)]">
|
||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-900">执行依据</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<RealMiniMetric label="模型决策" value={state.modelDecisions.length} />
|
||||
<RealMiniMetric label="测试记录" value={state.testRuns.length} />
|
||||
<RealMiniMetric label="发布对象" value={state.releases.length} />
|
||||
<RealMiniMetric label="指标信号" value={state.metricSignals.length} />
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{[...state.modelDecisions, ...state.testRuns].slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-[#edf2fb] bg-[#fbfdff] px-4 py-3">
|
||||
<div className="truncate text-sm font-semibold text-slate-850">{item.title}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{item.summary}</div>
|
||||
</div>
|
||||
))}
|
||||
{state.modelDecisions.length + state.testRuns.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dbe5f2] bg-[#fbfdff] px-4 py-6 text-sm text-slate-500">
|
||||
暂无模型决策或测试记录投影。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RealMiniMetric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-[#f7f9fc] px-4 py-3">
|
||||
<div className="text-xs font-semibold text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-xl font-semibold text-slate-950">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RealNextActionPanel({
|
||||
projectId,
|
||||
pendingChanges,
|
||||
activeTasks,
|
||||
blockedTasks,
|
||||
highRisks,
|
||||
}: {
|
||||
projectId: string;
|
||||
pendingChanges: number;
|
||||
activeTasks: number;
|
||||
blockedTasks: number;
|
||||
highRisks: number;
|
||||
}) {
|
||||
const actions = [
|
||||
{ label: "确认待审变更", value: pendingChanges, href: `/change-review?projectId=${projectId}` },
|
||||
{ label: "查看执行交接", value: activeTasks + blockedTasks, href: `/execution-bridge?projectId=${projectId}` },
|
||||
{ label: "处理高风险", value: highRisks, href: `/team-layer?projectId=${projectId}` },
|
||||
{ label: "进入发布增长", value: 0, href: `/release-growth?projectId=${projectId}` },
|
||||
];
|
||||
return (
|
||||
<section className="rounded-[18px] border border-[#e0e8f5] bg-white p-5 shadow-[0_8px_18px_rgba(15,23,42,0.035)]">
|
||||
<h2 className="text-base font-semibold text-slate-900">下一步动作</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{actions.map((action) => (
|
||||
<Link key={action.label} to={action.href} className="flex items-center justify-between rounded-xl border border-[#edf2fb] bg-[#fbfdff] px-4 py-3 text-sm font-semibold text-slate-700 transition hover:border-[#cfe0ff] hover:bg-[#f5f8ff]">
|
||||
<span>{action.label}</span>
|
||||
<span className="flex items-center gap-2 text-[#2463EB]">
|
||||
{action.value > 0 ? action.value : "打开"} <ChevronRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RealEvidencePanel({ evidence }: { evidence: ProjectTruthState["evidence"] }) {
|
||||
return (
|
||||
<section className="rounded-[18px] border border-[#e0e8f5] bg-white p-5 shadow-[0_8px_18px_rgba(15,23,42,0.035)]">
|
||||
<h2 className="text-base font-semibold text-slate-900">来源证据</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">信号源不再是独立阶段,只作为对象、决策和任务的来源证据保留。</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{evidence.slice(0, 6).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-[#edf2fb] bg-[#fbfdff] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToneBadge tone="brand">{item.kind}</ToneBadge>
|
||||
<div className="truncate text-sm font-semibold text-slate-850">{item.title}</div>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">{item.excerpt}</div>
|
||||
<div className="mt-2 text-xs text-slate-400">{item.sourceLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
{!evidence.length ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dbe5f2] bg-[#fbfdff] px-4 py-6 text-sm text-slate-500">
|
||||
暂无来源证据投影。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function stageLabel(stage: PMFlowStage): string {
|
||||
const labels: Record<PMFlowStage, string> = {
|
||||
idea: "创意",
|
||||
validation: "验证",
|
||||
planning: "规划",
|
||||
development: "开发",
|
||||
testing: "测试",
|
||||
release: "发布",
|
||||
growth: "增长",
|
||||
};
|
||||
return labels[stage] || stage;
|
||||
}
|
||||
|
||||
function statusLabel(status: PMFlowObjectStatus): string {
|
||||
const labels: Record<PMFlowObjectStatus, string> = {
|
||||
draft: "草稿",
|
||||
confirmed: "已确认",
|
||||
in_progress: "进行中",
|
||||
blocked: "阻塞",
|
||||
done: "完成",
|
||||
released: "已发布",
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function statusToneKey(status: PMFlowObjectStatus): "brand" | "success" | "warning" | "danger" {
|
||||
if (status === "done" || status === "released" || status === "confirmed") return "success";
|
||||
if (status === "blocked") return "danger";
|
||||
if (status === "in_progress") return "brand";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function riskLabel(level: ProjectTruthState["risks"][number]["level"]): string {
|
||||
if (level === "high") return "高风险";
|
||||
if (level === "medium") return "中风险";
|
||||
return "低风险";
|
||||
}
|
||||
|
||||
function StageRail() {
|
||||
return (
|
||||
<section className="rounded-[22px] border border-[#dfe8f7] bg-white shadow-[0_10px_24px_rgba(15,23,42,0.04)]">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
createPMFlowReleaseFeedbackIngest,
|
||||
createPMFlowReviewDecision,
|
||||
createPMFlowThreadMessage,
|
||||
getPMFlowExecutionCapabilities,
|
||||
deferPMFlowChangeItem,
|
||||
editPMFlowChangeItem,
|
||||
getPMFlowExecutionSummary,
|
||||
|
|
@ -44,6 +45,7 @@ vi.mock("@/api/accountSettings", () => ({
|
|||
vi.mock("@/api/pmflowOs", () => ({
|
||||
getPMFlowTruthState: vi.fn(),
|
||||
getPMFlowExecutionSummary: vi.fn(),
|
||||
getPMFlowExecutionCapabilities: vi.fn(),
|
||||
createPMFlowThreadMessage: vi.fn(),
|
||||
createPMFlowExtraction: vi.fn(),
|
||||
getPMFlowChangeSet: vi.fn(),
|
||||
|
|
@ -63,6 +65,7 @@ vi.mock("@/api/pmflowOs", () => ({
|
|||
|
||||
const getTruthStateMock = vi.mocked(getPMFlowTruthState);
|
||||
const getExecutionSummaryMock = vi.mocked(getPMFlowExecutionSummary);
|
||||
const getExecutionCapabilitiesMock = vi.mocked(getPMFlowExecutionCapabilities);
|
||||
const getAccountSettingsMock = vi.mocked(getAccountSettings);
|
||||
const createThreadMessageMock = vi.mocked(createPMFlowThreadMessage);
|
||||
const createExecutionHandoffMock = vi.mocked(createPMFlowExecutionHandoff);
|
||||
|
|
@ -320,6 +323,69 @@ describe("PMFlowOsProvider real change review actions", () => {
|
|||
recentDeliveries: [],
|
||||
warnings: [],
|
||||
});
|
||||
getExecutionCapabilitiesMock.mockResolvedValue({
|
||||
projectId: "123",
|
||||
executionModes: [
|
||||
{
|
||||
mode: "ai_with_user_acceptance",
|
||||
label: "AI 执行 + 用户验收",
|
||||
status: "partial",
|
||||
reason: "等待 repo 与执行证据补齐。",
|
||||
targetHref: "/execution-bridge?projectId=123",
|
||||
},
|
||||
],
|
||||
personalReadiness: {
|
||||
status: "partial",
|
||||
toolReadiness: [
|
||||
{
|
||||
key: "repo",
|
||||
label: "Repo 连接",
|
||||
status: "blocked",
|
||||
summary: "未接入 repo,AI 执行包缺少真实代码落点。",
|
||||
href: "/execution-bridge?projectId=123",
|
||||
},
|
||||
],
|
||||
waitingForUser: {
|
||||
pendingConfirmations: 1,
|
||||
pendingReviews: 1,
|
||||
waitingAcceptance: 0,
|
||||
},
|
||||
blockers: ["未接入 repo,AI 执行包缺少真实代码落点。"],
|
||||
recommendedNextStep: "先处理分配给你的审查项,再继续推进 AI 执行。",
|
||||
targetHref: "/change-review?projectId=123",
|
||||
},
|
||||
teamSignals: [
|
||||
{
|
||||
userId: "9",
|
||||
displayName: "审查负责人",
|
||||
role: "REVIEWER",
|
||||
judgementResponsibilities: ["技术/产品审查"],
|
||||
reviewResponsibilities: ["实现风险"],
|
||||
capacity: {
|
||||
status: "ready",
|
||||
label: "可承接",
|
||||
workloadRatio: 32,
|
||||
pendingReviewCount: 1,
|
||||
activeExecutionCount: 0,
|
||||
},
|
||||
context: {
|
||||
status: "partial",
|
||||
label: "有项目证据或能力参考",
|
||||
evidenceCount: 1,
|
||||
recentContributionCount: 1,
|
||||
},
|
||||
toolStatus: {
|
||||
status: "unknown",
|
||||
label: "未接入",
|
||||
connectedTools: [],
|
||||
},
|
||||
referenceSkills: ["审查责任"],
|
||||
recommendedModes: ["ai_with_technical_review"],
|
||||
warnings: ["未发现活跃 IDE / Agent 工具连接。"],
|
||||
},
|
||||
],
|
||||
warnings: ["repo_missing: 未接入 repo,AI 执行包无法落到真实代码上下文。"],
|
||||
});
|
||||
createThreadMessageMock.mockResolvedValue({
|
||||
id: "41",
|
||||
threadId: "7",
|
||||
|
|
@ -554,9 +620,10 @@ describe("PMFlowOsProvider real change review actions", () => {
|
|||
);
|
||||
|
||||
expect(await screen.findByText("待审 ChangeSet 审查路由")).toBeInTheDocument();
|
||||
expect(await screen.findByText("执行能力与容量信号")).toBeInTheDocument();
|
||||
expect(await screen.findByText("真实变更集")).toBeInTheDocument();
|
||||
expect(screen.getByText("审查负责人")).toBeInTheDocument();
|
||||
expect(screen.getByText("REVIEWER")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("审查负责人").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("REVIEWER").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("下一轮迭代")).toBeInTheDocument();
|
||||
expect(screen.getByText("证据 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("已请求 ChangeSet 审查")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -211,6 +211,26 @@ export type PMFlowReviewerConfidence = "low" | "medium" | "high";
|
|||
export type PMFlowFallbackReviewerRole = "OWNER" | "REVIEWER" | "MEMBER";
|
||||
export type PMFlowReviewDecisionAction = "request_review" | "approve" | "request_changes" | "reject";
|
||||
export type PMFlowApprovalState = "not_requested" | "requested" | "approved" | "changes_requested" | "rejected";
|
||||
export type PMFlowExecutionCapabilityMode =
|
||||
| "ai_direct"
|
||||
| "ai_with_user_acceptance"
|
||||
| "ai_with_technical_review"
|
||||
| "needs_human_judgement"
|
||||
| "blocked";
|
||||
export type PMFlowExecutionCapabilityStatus = "ready" | "partial" | "blocked" | "unknown";
|
||||
export type PMFlowHumanWorkType = "focus" | "review" | "acceptance" | "coordination";
|
||||
export type PMFlowCapacityStatus = "normal" | "attention" | "near_full" | "overloaded" | "blocked" | "manually_extended";
|
||||
export type PMFlowCapacityFitStatus = "recommended" | "allowed_with_warning" | "not_recommended" | "blocked";
|
||||
export type PMFlowCapacityOverrideType = "extend_minutes" | "overtime" | "lock_capacity" | "work_type_bias" | "high_risk_exception";
|
||||
export type PMFlowOvertimeType = "general" | "focus" | "review" | "release_guard";
|
||||
export type PMFlowHumanTouchSourceType =
|
||||
| "changeset_confirmation"
|
||||
| "review"
|
||||
| "acceptance"
|
||||
| "release_decision"
|
||||
| "context_completion"
|
||||
| "ai_failure_recovery";
|
||||
export type PMFlowHumanTouchBindingType = "hard_bound" | "role_bound" | "ai_shiftable";
|
||||
|
||||
export interface PMFlowReviewerSuggestion {
|
||||
userId?: string | null;
|
||||
|
|
@ -294,3 +314,184 @@ export interface ProjectTruthState {
|
|||
selectedTaskId: string | null;
|
||||
execution: PMFlowExecutionContext;
|
||||
}
|
||||
|
||||
export interface PMFlowExecutionModeCapability {
|
||||
mode: PMFlowExecutionCapabilityMode;
|
||||
label: string;
|
||||
status: PMFlowExecutionCapabilityStatus;
|
||||
reason: string;
|
||||
targetHref?: string | null;
|
||||
}
|
||||
|
||||
export interface PMFlowToolReadinessItem {
|
||||
key: string;
|
||||
label: string;
|
||||
status: PMFlowExecutionCapabilityStatus;
|
||||
summary: string;
|
||||
href?: string | null;
|
||||
}
|
||||
|
||||
export interface PMFlowPersonalExecutionReadiness {
|
||||
status: PMFlowExecutionCapabilityStatus;
|
||||
toolReadiness: PMFlowToolReadinessItem[];
|
||||
waitingForUser: {
|
||||
pendingConfirmations?: number;
|
||||
pendingReviews?: number;
|
||||
waitingAcceptance?: number;
|
||||
[key: string]: number | undefined;
|
||||
};
|
||||
blockers: string[];
|
||||
recommendedNextStep: string;
|
||||
targetHref: string;
|
||||
}
|
||||
|
||||
export interface PMFlowTeamExecutionSignal {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
judgementResponsibilities: string[];
|
||||
reviewResponsibilities: string[];
|
||||
capacity: {
|
||||
status: PMFlowExecutionCapabilityStatus;
|
||||
label: string;
|
||||
workloadRatio: number;
|
||||
pendingReviewCount: number;
|
||||
activeExecutionCount: number;
|
||||
};
|
||||
context: {
|
||||
status: PMFlowExecutionCapabilityStatus;
|
||||
label: string;
|
||||
evidenceCount: number;
|
||||
recentContributionCount: number;
|
||||
};
|
||||
toolStatus: {
|
||||
status: PMFlowExecutionCapabilityStatus;
|
||||
label: string;
|
||||
connectedTools: string[];
|
||||
};
|
||||
referenceSkills: string[];
|
||||
recommendedModes: PMFlowExecutionCapabilityMode[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PMFlowWorkTypeDistribution {
|
||||
focus: number;
|
||||
review: number;
|
||||
acceptance: number;
|
||||
coordination: number;
|
||||
}
|
||||
|
||||
export interface PMFlowWorkTypeMinutes {
|
||||
focus: number;
|
||||
review: number;
|
||||
acceptance: number;
|
||||
coordination: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PMFlowHumanTouchEstimate {
|
||||
sourceType: PMFlowHumanTouchSourceType;
|
||||
sourceId: string;
|
||||
targetHref: string;
|
||||
workType: PMFlowHumanWorkType;
|
||||
estimatedMinutes: number;
|
||||
riskLevel: PMFlowRiskLevel;
|
||||
urgency: string;
|
||||
bindingType: PMFlowHumanTouchBindingType;
|
||||
assignedUserId?: string | null;
|
||||
roleBinding?: string | null;
|
||||
contextReadiness: PMFlowExecutionCapabilityStatus;
|
||||
aiReductionPotentialMinutes: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface PMFlowCapacityFit {
|
||||
workType: PMFlowHumanWorkType;
|
||||
label: string;
|
||||
status: PMFlowCapacityFitStatus;
|
||||
estimatedMinutes: number;
|
||||
afterTotalUtilization: number;
|
||||
afterTypeShare: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PMFlowCapacityOverridePayload {
|
||||
userId: string;
|
||||
date?: string | null;
|
||||
overrideType: PMFlowCapacityOverrideType;
|
||||
addedMinutes?: number;
|
||||
overtimeType?: PMFlowOvertimeType | null;
|
||||
maxPlannedUtilization?: number | null;
|
||||
workTypeBias?: Partial<Record<PMFlowHumanWorkType, number>>;
|
||||
highRiskLimit?: number | null;
|
||||
reason: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PMFlowCapacityOverrideRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
overrideType: PMFlowCapacityOverrideType;
|
||||
addedMinutes: number;
|
||||
overtimeType?: PMFlowOvertimeType | null;
|
||||
maxPlannedUtilization?: number | null;
|
||||
workTypeBias: Partial<Record<PMFlowHumanWorkType, number>>;
|
||||
highRiskLimit?: number | null;
|
||||
reason: string;
|
||||
expiresAt: string;
|
||||
createdBy: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PMFlowMemberCapacityPlan {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
baseAvailableMinutes: number;
|
||||
calendarBlockedMinutes: number;
|
||||
effectiveMinutes: number;
|
||||
reservedBufferMinutes: number;
|
||||
maxPlannedUtilization: number;
|
||||
suggestedBaseline: PMFlowWorkTypeDistribution;
|
||||
currentMix: PMFlowWorkTypeDistribution;
|
||||
plannedTouchMinutes: PMFlowWorkTypeMinutes;
|
||||
capacityStatus: PMFlowCapacityStatus;
|
||||
remainingMinutes: number;
|
||||
bottleneckType?: PMFlowHumanWorkType | null;
|
||||
fatigueRisk: Record<string, string>;
|
||||
riskDensity: Record<string, number>;
|
||||
canTakeMore: PMFlowCapacityFit[];
|
||||
manualOverride?: PMFlowCapacityOverrideRecord | null;
|
||||
explanations: string[];
|
||||
}
|
||||
|
||||
export interface PMFlowDailyCapacityPlan {
|
||||
projectId: string;
|
||||
date: string;
|
||||
generatedAt: string;
|
||||
teamDemandMix: PMFlowWorkTypeDistribution;
|
||||
teamEffectiveMinutes: number;
|
||||
teamPlannedTouchMinutes: PMFlowWorkTypeMinutes;
|
||||
memberPlans: PMFlowMemberCapacityPlan[];
|
||||
humanTouches: PMFlowHumanTouchEstimate[];
|
||||
warnings: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface PMFlowCapacityOverrideSimulation {
|
||||
beforePlan: PMFlowMemberCapacityPlan;
|
||||
afterPlan: PMFlowMemberCapacityPlan;
|
||||
impactSummary: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PMFlowExecutionCapabilitySnapshot {
|
||||
projectId: string;
|
||||
executionModes: PMFlowExecutionModeCapability[];
|
||||
personalReadiness: PMFlowPersonalExecutionReadiness;
|
||||
teamSignals: PMFlowTeamExecutionSignal[];
|
||||
adaptiveCapacitySummary?: PMFlowDailyCapacityPlan | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -755,26 +755,119 @@ export function FailureStatePanel({
|
|||
onRetry: () => void;
|
||||
onBackToInput: () => void;
|
||||
}) {
|
||||
const reasons = [
|
||||
{ title: "网络异常", label: "常见", detail: "连接不稳定或服务器暂时不可用,建议检查网络后重试。", tone: "danger" as const },
|
||||
{ title: "材料解析失败", label: "常见", detail: "导入的文档格式不被支持,或内容解析过程出现异常。", tone: "warning" as const },
|
||||
{ title: "内容过短或信息不足", label: "可能原因", detail: "输入信息较少,AI 难以提取有效结构化对象。", tone: "warning" as const },
|
||||
{ title: "触发内容安全策略", label: "少见", detail: "输入内容可能包含敏感词或违规信息,建议调整后再试。", tone: "brand" as const },
|
||||
];
|
||||
const isAuthFailure = snapshot.failureKind === "auth_required" || snapshot.failureKind === "session_expired";
|
||||
const isServiceFailure = snapshot.failureKind === "system_failed" || snapshot.failureKind === "ai_generation_failed";
|
||||
const failureCopy = (() => {
|
||||
if (isAuthFailure) {
|
||||
return {
|
||||
badge: "需登录",
|
||||
heading: "新项目启动台",
|
||||
lead: "登录状态已失效。你的输入已保留,重新登录后可以继续生成项目草案。",
|
||||
title: "需要重新登录",
|
||||
detail: snapshot.errorMessage || "登录状态已失效。你的输入已保留,重新登录后可以继续生成项目草案。",
|
||||
primary: "重新登录并继续",
|
||||
};
|
||||
}
|
||||
if (snapshot.failureKind === "permission_denied") {
|
||||
return {
|
||||
badge: "无权限",
|
||||
heading: "新项目启动台",
|
||||
lead: "当前账号没有执行生成操作的权限。你的输入已保留,可以返回个人空间或切换账号后继续。",
|
||||
title: "没有生成权限",
|
||||
detail: snapshot.errorMessage || "当前账号没有执行此操作的权限。",
|
||||
primary: "重新尝试",
|
||||
};
|
||||
}
|
||||
if (snapshot.failureKind === "quota_limited") {
|
||||
return {
|
||||
badge: "额度受限",
|
||||
heading: "新项目启动台",
|
||||
lead: "当前 AI 额度不足或请求过于频繁。你的输入已保留,稍后可以直接重试。",
|
||||
title: "AI 额度暂时受限",
|
||||
detail: snapshot.errorMessage || "当前额度不足或请求过于频繁,请稍后重试。",
|
||||
primary: "稍后重试",
|
||||
};
|
||||
}
|
||||
if (snapshot.failureKind === "input_invalid") {
|
||||
return {
|
||||
badge: "需补充",
|
||||
heading: "新项目启动台",
|
||||
lead: "当前输入还不足以生成稳定的项目草案。你可以补充目标用户、核心场景和约束后再次生成。",
|
||||
title: "需要补充输入",
|
||||
detail: snapshot.errorMessage || "输入信息需要调整后再继续。",
|
||||
primary: "重新生成",
|
||||
};
|
||||
}
|
||||
if (snapshot.failureKind === "network_unavailable") {
|
||||
return {
|
||||
badge: "连接中断",
|
||||
heading: "新项目启动台",
|
||||
lead: "当前连接没有完成。你的输入已保留,网络恢复后可以直接重试。",
|
||||
title: "服务连接异常",
|
||||
detail: snapshot.errorMessage || "服务连接异常,请稍后重试。",
|
||||
primary: "重新连接并生成",
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: "服务异常",
|
||||
heading: "新项目启动台",
|
||||
lead: "AI 生成服务暂时没有完成项目草案。你的输入已保留,草案未确认前不会写入项目记忆。",
|
||||
title: "AI 生成服务暂不可用",
|
||||
detail: snapshot.errorMessage || "AI 生成服务暂时不可用。你的输入已保留,系统已记录问题。",
|
||||
primary: "稍后重新生成",
|
||||
};
|
||||
})();
|
||||
const reasons = (() => {
|
||||
if (isAuthFailure) {
|
||||
return [
|
||||
{ title: "登录状态失效", label: "需要处理", detail: "当前会话已过期或未登录,系统没有执行草案生成。", tone: "danger" as const },
|
||||
{ title: "输入已保留", label: "已保存", detail: "重新登录后会恢复当前输入,不会要求你重新填写。", tone: "brand" as const },
|
||||
{ title: "未写入项目记忆", label: "安全", detail: "草案未确认前不会创建项目、任务或项目记忆。", tone: "brand" as const },
|
||||
];
|
||||
}
|
||||
if (isServiceFailure) {
|
||||
return [
|
||||
{ title: "AI 生成服务暂不可用", label: "已记录", detail: "系统没有拿到可用的结构化草案结果。你的输入已保留,可以稍后重试。", tone: "danger" as const },
|
||||
{ title: "输入已保留", label: "已保存", detail: "当前想法仍保存在启动台,不需要重新填写。", tone: "brand" as const },
|
||||
{ title: "未写入项目记忆", label: "安全", detail: "失败草案不会创建项目、任务,也不会污染项目记忆。", tone: "brand" as const },
|
||||
{ title: "可继续编辑", label: "建议", detail: "如果要立即重试,可以先补充目标用户、成功标准或关键约束,提高生成稳定性。", tone: "warning" as const },
|
||||
];
|
||||
}
|
||||
if (snapshot.failureKind === "network_unavailable") {
|
||||
return [
|
||||
{ title: "服务连接异常", label: "可重试", detail: "请求没有稳定连接到服务端。网络恢复后可直接重新生成。", tone: "danger" as const },
|
||||
{ title: "输入已保留", label: "已保存", detail: "当前输入仍在本地启动台状态中。", tone: "brand" as const },
|
||||
{ title: "未写入项目记忆", label: "安全", detail: "草案未确认前不会创建项目、任务或项目记忆。", tone: "brand" as const },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ title: "输入需要补充", label: "建议", detail: "建议补充目标用户、核心场景、成功标准和关键约束。", tone: "warning" as const },
|
||||
{ title: "材料解析可能失败", label: "可能原因", detail: "导入的文档格式不被支持,或内容解析过程出现异常。", tone: "warning" as const },
|
||||
{ title: "输入已保留", label: "已保存", detail: "返回编辑后可以继续补充,不需要重新开始。", tone: "brand" as const },
|
||||
{ title: "未写入项目记忆", label: "安全", detail: "未确认草案不会被写入项目主链。", tone: "brand" as const },
|
||||
];
|
||||
})();
|
||||
const handlePrimary = () => {
|
||||
if (isAuthFailure) {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent("/project-launch")}`;
|
||||
return;
|
||||
}
|
||||
onRetry();
|
||||
};
|
||||
return (
|
||||
<div className="grid min-h-full grid-cols-[minmax(0,1fr)_390px] gap-12 px-10 py-9 2xl:max-w-[1560px] 2xl:mx-auto 2xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-[-0.04em] text-slate-950">
|
||||
新项目启动台 <span className="ml-3 rounded-lg bg-red-50 px-3 py-1 text-sm text-red-500">失败态</span>
|
||||
{failureCopy.heading} <span className="ml-3 rounded-lg bg-red-50 px-3 py-1 text-sm text-red-500">{failureCopy.badge}</span>
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] text-slate-500">AI 在生成项目草案时遇到问题,系统已为你保留输入内容,请检查后重试或继续补充信息。</p>
|
||||
<p className="mt-3 text-[15px] text-slate-500">{failureCopy.lead}</p>
|
||||
</div>
|
||||
<PageBanner
|
||||
tone="danger"
|
||||
className="rounded-xl border-dashed py-8"
|
||||
title="生成失败"
|
||||
detail={snapshot.errorMessage || "AI 在生成草案时遇到问题。系统已保留你的输入,你可以修改后重新生成或补充更多信息。"}
|
||||
title={failureCopy.title}
|
||||
detail={failureCopy.detail}
|
||||
/>
|
||||
<div className="mt-6 rounded-xl border border-[#dfe7f3] bg-white p-5">
|
||||
<InlineNotice tone="success" title="系统已保存" className="mb-4">
|
||||
|
|
@ -795,9 +888,9 @@ export function FailureStatePanel({
|
|||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-5">
|
||||
<Button className="h-20 rounded-xl bg-[#2463EB] text-lg font-semibold hover:bg-[#1f58d5]" onClick={onRetry}>
|
||||
<Button className="h-20 rounded-xl bg-[#2463EB] text-lg font-semibold hover:bg-[#1f58d5]" onClick={handlePrimary}>
|
||||
<RefreshCcw className="mr-2 h-5 w-5" />
|
||||
重新生成
|
||||
{failureCopy.primary}
|
||||
</Button>
|
||||
<Button variant="outline" className="h-20 rounded-xl text-lg font-semibold" onClick={onBackToInput}>
|
||||
<Pencil className="mr-2 h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FailureStatePanel } from "@/features/project-launch/components/LaunchCanvases";
|
||||
import { launchInputMock, projectDraftMock, systemStatusMock, teamHintMock } from "@/features/project-launch/mockData";
|
||||
import type { LaunchStateSnapshot } from "@/features/project-launch/types";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
const authFailureSnapshot: LaunchStateSnapshot = {
|
||||
stage: "generation_failed",
|
||||
dataSourceMode: "api",
|
||||
input: { ...launchInputMock, text: "我想做一个 AI 项目启动台" },
|
||||
draft: projectDraftMock,
|
||||
editingDraft: projectDraftMock,
|
||||
systemStatus: systemStatusMock,
|
||||
teamHint: teamHintMock,
|
||||
activeStepIndex: 0,
|
||||
visibleOperationCount: 1,
|
||||
paused: false,
|
||||
elapsedSeconds: 0,
|
||||
savedIdea: false,
|
||||
draftId: null,
|
||||
redirectUrl: null,
|
||||
errorMessage: "登录状态已失效,重新登录后可以继续。",
|
||||
failureKind: "session_expired",
|
||||
warnings: [],
|
||||
rawDraft: null,
|
||||
};
|
||||
|
||||
const systemFailureSnapshot: LaunchStateSnapshot = {
|
||||
...authFailureSnapshot,
|
||||
errorMessage: "AI 生成服务暂时不可用。你的输入已保留,系统已记录问题。",
|
||||
failureKind: "system_failed",
|
||||
};
|
||||
|
||||
describe("FailureStatePanel", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders auth failure as login continuation instead of network generation failure", () => {
|
||||
renderWithProviders(
|
||||
<FailureStatePanel snapshot={authFailureSnapshot} onRetry={vi.fn()} onBackToInput={vi.fn()} />,
|
||||
["/project-launch"]
|
||||
);
|
||||
|
||||
expect(screen.getByText("需要重新登录")).toBeInTheDocument();
|
||||
expect(screen.getByText("重新登录并继续")).toBeInTheDocument();
|
||||
expect(screen.getByText("登录状态失效")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("输入已保留").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("未写入项目记忆")).toBeInTheDocument();
|
||||
expect(screen.queryByText("网络异常")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Request failed with status code 401/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders system generation failure without blaming network by default", () => {
|
||||
renderWithProviders(
|
||||
<FailureStatePanel snapshot={systemFailureSnapshot} onRetry={vi.fn()} onBackToInput={vi.fn()} />,
|
||||
["/project-launch"]
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("AI 生成服务暂不可用").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("AI 生成服务暂时不可用。你的输入已保留,系统已记录问题。")).toBeInTheDocument();
|
||||
expect(screen.getByText("输入已保留")).toBeInTheDocument();
|
||||
expect(screen.getByText("未写入项目记忆")).toBeInTheDocument();
|
||||
expect(screen.queryByText("网络异常")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -143,6 +143,20 @@ function MobileIntentState({
|
|||
onStart,
|
||||
}: MobileLaunchLayoutProps) {
|
||||
const canStart = snapshot.input.text.trim().length > 0;
|
||||
const isAuthFailure = snapshot.failureKind === "auth_required" || snapshot.failureKind === "session_expired";
|
||||
const failureMessage = (() => {
|
||||
if (isAuthFailure) return "需要重新登录。你的输入已保留,登录后可以继续生成项目草案。";
|
||||
if (snapshot.failureKind === "system_failed" || snapshot.failureKind === "ai_generation_failed") {
|
||||
return snapshot.errorMessage || "AI 生成服务暂时不可用。你的输入已保留,可以稍后重试。";
|
||||
}
|
||||
if (snapshot.failureKind === "network_unavailable") {
|
||||
return snapshot.errorMessage || "服务连接异常。你的输入已保留,网络恢复后可以重试。";
|
||||
}
|
||||
if (snapshot.failureKind === "input_invalid") {
|
||||
return snapshot.errorMessage || "当前输入需要补充后再继续生成。";
|
||||
}
|
||||
return snapshot.errorMessage || "项目草案没有生成完成。你的输入已保留,可以返回编辑后重试。";
|
||||
})();
|
||||
const modes: Array<{ mode: LaunchInputMode; label: string; icon: typeof Sparkles }> = [
|
||||
{ mode: "idea", label: "说出想法", icon: Sparkles },
|
||||
{ mode: "import", label: "导入材料", icon: FileInput },
|
||||
|
|
@ -156,7 +170,7 @@ function MobileIntentState({
|
|||
<p className="mt-3 text-[22px] leading-8 text-slate-500">说出你的产品想法,PMFlow 会生成项目草案与执行建议</p>
|
||||
{snapshot.stage === "generation_failed" ? (
|
||||
<div className="mt-5 rounded-2xl border border-red-100 bg-red-50 px-5 py-4 text-[19px] leading-7 text-red-600">
|
||||
{snapshot.errorMessage || "项目草案生成失败,请修改输入后重试。"}
|
||||
{failureMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-6 grid grid-cols-3 rounded-2xl border border-[#dfe7f3] bg-white p-1">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ export type LaunchStage =
|
|||
|
||||
export type LaunchInputMode = "idea" | "import" | "template";
|
||||
|
||||
export type LaunchFailureKind =
|
||||
| "auth_required"
|
||||
| "session_expired"
|
||||
| "permission_denied"
|
||||
| "not_found_or_no_access"
|
||||
| "quota_limited"
|
||||
| "input_invalid"
|
||||
| "network_unavailable"
|
||||
| "ai_generation_failed"
|
||||
| "system_failed";
|
||||
|
||||
export type AIWorkStepStatus = "pending" | "active" | "done" | "failed";
|
||||
|
||||
export interface LaunchInput {
|
||||
|
|
@ -115,6 +126,7 @@ export interface LaunchStateSnapshot {
|
|||
draftId: string | null;
|
||||
redirectUrl: string | null;
|
||||
errorMessage: string | null;
|
||||
failureKind: LaunchFailureKind | null;
|
||||
warnings: string[];
|
||||
rawDraft: Record<string, unknown> | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { useProjectLaunchState } from "@/features/project-launch/useProjectLaunchState";
|
||||
import { confirmPMFlowLaunchDraft, createPMFlowLaunchDraft, type PMFlowLaunchDraft } from "@/api/pmflowLaunch";
|
||||
import { savePendingIntent } from "@/lib/pendingIntent";
|
||||
|
||||
vi.mock("@/api/pmflowLaunch", () => ({
|
||||
createPMFlowLaunchDraft: vi.fn(),
|
||||
|
|
@ -65,11 +66,16 @@ const draftPayload: PMFlowLaunchDraft = {
|
|||
describe("useProjectLaunchState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("pmflow_token", "token-1");
|
||||
window.history.pushState({}, "", "/project-launch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("uses the real launch draft API by default", async () => {
|
||||
|
|
@ -103,10 +109,70 @@ describe("useProjectLaunchState", () => {
|
|||
act(() => result.current.startGeneration());
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed"));
|
||||
expect(result.current.snapshot.errorMessage).toBe("draft_structured_failed");
|
||||
expect(result.current.snapshot.failureKind).toBe("ai_generation_failed");
|
||||
expect(result.current.snapshot.errorMessage).toBe("AI 暂时没有完成项目草案生成。你的输入已保留,可以稍后重试。");
|
||||
expect(result.current.snapshot.draftId).toBeNull();
|
||||
});
|
||||
|
||||
it("maps 401 draft failure to session_expired and hides raw axios copy", async () => {
|
||||
createDraftMock.mockRejectedValueOnce({
|
||||
message: "Request failed with status code 401",
|
||||
response: { status: 401, data: { detail: "Could not validate credentials" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProjectLaunchState());
|
||||
|
||||
act(() => result.current.updateInput("我想做一个 AI 项目"));
|
||||
act(() => result.current.startGeneration());
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed"));
|
||||
expect(result.current.snapshot.failureKind).toBe("session_expired");
|
||||
expect(result.current.snapshot.errorMessage).toBe("登录状态已失效,重新登录后可以继续。");
|
||||
expect(result.current.snapshot.errorMessage).not.toContain("Request failed with status code 401");
|
||||
});
|
||||
|
||||
it("maps 403 draft failure to permission_denied", async () => {
|
||||
createDraftMock.mockRejectedValueOnce({
|
||||
message: "Request failed with status code 403",
|
||||
response: { status: 403, data: { detail: "Access denied" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProjectLaunchState());
|
||||
|
||||
act(() => result.current.updateInput("我想做一个 AI 项目"));
|
||||
act(() => result.current.startGeneration());
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed"));
|
||||
expect(result.current.snapshot.failureKind).toBe("permission_denied");
|
||||
expect(result.current.snapshot.errorMessage).toBe("当前账号没有执行此操作的权限。");
|
||||
});
|
||||
|
||||
it("maps network draft failure to network_unavailable", async () => {
|
||||
createDraftMock.mockRejectedValueOnce({ code: "ERR_NETWORK", message: "Network Error" });
|
||||
|
||||
const { result } = renderHook(() => useProjectLaunchState());
|
||||
|
||||
act(() => result.current.updateInput("我想做一个 AI 项目"));
|
||||
act(() => result.current.startGeneration());
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed"));
|
||||
expect(result.current.snapshot.failureKind).toBe("network_unavailable");
|
||||
expect(result.current.snapshot.errorMessage).toBe("服务连接异常,请稍后重试。");
|
||||
});
|
||||
|
||||
it("saves pending input and does not call the draft API when token is missing", async () => {
|
||||
localStorage.removeItem("pmflow_token");
|
||||
|
||||
const { result } = renderHook(() => useProjectLaunchState());
|
||||
|
||||
act(() => result.current.updateInput("登录后继续的启动台输入"));
|
||||
act(() => result.current.startGeneration());
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed"));
|
||||
expect(result.current.snapshot.failureKind).toBe("auth_required");
|
||||
expect(createDraftMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms through the real API and stores the backend redirect URL", async () => {
|
||||
createDraftMock.mockResolvedValueOnce(draftPayload);
|
||||
confirmDraftMock.mockResolvedValueOnce({
|
||||
|
|
@ -146,7 +212,8 @@ describe("useProjectLaunchState", () => {
|
|||
act(() => result.current.createProject());
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot.stage).toBe("generation_failed"));
|
||||
expect(result.current.snapshot.errorMessage).toBe("confirm_failed");
|
||||
expect(result.current.snapshot.failureKind).toBe("system_failed");
|
||||
expect(result.current.snapshot.errorMessage).toBe("操作没有完成,请稍后重试。");
|
||||
expect(result.current.snapshot.redirectUrl).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -159,4 +226,19 @@ describe("useProjectLaunchState", () => {
|
|||
expect(result.current.snapshot.input.text).toContain("AI 周报工具");
|
||||
expect(createDraftMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores pending project launch input without submitting it", () => {
|
||||
sessionStorage.clear();
|
||||
savePendingIntent({
|
||||
intentType: "project_launch_input",
|
||||
route: "/project-launch",
|
||||
payload: { text: "恢复登录前输入", mode: "idea" },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProjectLaunchState());
|
||||
|
||||
expect(result.current.snapshot.input.text).toBe("恢复登录前输入");
|
||||
expect(result.current.snapshot.stage).toBe("intent_input");
|
||||
expect(createDraftMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import {
|
|||
type PMFlowLaunchDraft,
|
||||
type PMFlowLaunchDraftItem,
|
||||
} from "@/api/pmflowLaunch";
|
||||
import { getAuthToken } from "@/lib/api";
|
||||
import { normalizeApiError } from "@/lib/apiError";
|
||||
import { consumePendingIntent, savePendingIntent, type ProjectLaunchPendingPayload } from "@/lib/pendingIntent";
|
||||
import {
|
||||
aiOperationItemsMock,
|
||||
aiWorkStepsMock,
|
||||
|
|
@ -66,6 +69,29 @@ function getDataSourceMode(): "api" | "demo" {
|
|||
return params.get("demo") === "1" || params.get("dataSource") === "demo" ? "demo" : "api";
|
||||
}
|
||||
|
||||
function getInitialInput(dataSourceMode: "api" | "demo") {
|
||||
if (dataSourceMode === "demo") return { ...launchInputMock };
|
||||
const pending = consumePendingIntent<ProjectLaunchPendingPayload>("project_launch_input");
|
||||
if (pending?.payload?.text) {
|
||||
return {
|
||||
mode: pending.payload.mode || "idea",
|
||||
text: pending.payload.text,
|
||||
};
|
||||
}
|
||||
return { mode: "idea" as const, text: "" };
|
||||
}
|
||||
|
||||
function saveProjectLaunchInput(input: LaunchStateSnapshot["input"]): void {
|
||||
savePendingIntent<ProjectLaunchPendingPayload>({
|
||||
intentType: "project_launch_input",
|
||||
route: "/project-launch",
|
||||
payload: {
|
||||
text: input.text,
|
||||
mode: input.mode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function draftGroupLabel(group: PMFlowLaunchDraftItem["group"]): ProjectDraft["firstTasks"][number]["group"] {
|
||||
if (group === "engineering") return "开发任务";
|
||||
if (group === "qa") return "测试任务";
|
||||
|
|
@ -149,10 +175,11 @@ export function useProjectLaunchState() {
|
|||
const stage = dataSourceMode === "demo" ? getInitialStage() : "intent_input";
|
||||
const activeStepIndex = stage === "ai_working" ? 2 : stage === "intent_input" || stage === "generation_failed" ? 0 : terminalStepIndex;
|
||||
const initialDraft = dataSourceMode === "demo" ? cloneDraft(projectDraftMock) : cloneDraft(emptyProjectDraft);
|
||||
const initialInput = getInitialInput(dataSourceMode);
|
||||
return {
|
||||
dataSourceMode,
|
||||
stage,
|
||||
input: dataSourceMode === "demo" ? { ...launchInputMock } : { mode: "idea", text: "" },
|
||||
input: initialInput,
|
||||
draft: initialDraft,
|
||||
editingDraft: cloneDraft(initialDraft),
|
||||
systemStatus: systemStatusMock,
|
||||
|
|
@ -165,6 +192,7 @@ export function useProjectLaunchState() {
|
|||
draftId: null,
|
||||
redirectUrl: null,
|
||||
errorMessage: null,
|
||||
failureKind: null,
|
||||
warnings: [],
|
||||
rawDraft: null,
|
||||
};
|
||||
|
|
@ -243,8 +271,21 @@ export function useProjectLaunchState() {
|
|||
elapsedSeconds: 0,
|
||||
savedIdea: false,
|
||||
errorMessage: null,
|
||||
failureKind: null,
|
||||
warnings: [],
|
||||
}));
|
||||
if (!getAuthToken()) {
|
||||
saveProjectLaunchInput(snapshot.input);
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
stage: "generation_failed",
|
||||
activeStepIndex: 0,
|
||||
visibleOperationCount: Math.max(current.visibleOperationCount, 1),
|
||||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续生成项目草案。",
|
||||
failureKind: "auth_required",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
void createPMFlowLaunchDraft({
|
||||
input,
|
||||
input_type: "text",
|
||||
|
|
@ -263,16 +304,22 @@ export function useProjectLaunchState() {
|
|||
draftId: payload.draft_id,
|
||||
rawDraft: payload as unknown as Record<string, unknown>,
|
||||
warnings: payload.warnings || [],
|
||||
failureKind: null,
|
||||
systemStatus: { ...current.systemStatus, message: "真实项目草案已生成,等待确认", updatedAt: "刚刚" },
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
const normalized = normalizeApiError(error, "ai_generation_failed");
|
||||
if (normalized.kind === "auth_required" || normalized.kind === "session_expired") {
|
||||
saveProjectLaunchInput(snapshot.input);
|
||||
}
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
stage: "generation_failed",
|
||||
activeStepIndex: Math.min(current.activeStepIndex, 2),
|
||||
visibleOperationCount: Math.max(current.visibleOperationCount, 1),
|
||||
errorMessage: error instanceof Error ? error.message : "项目草案生成失败",
|
||||
errorMessage: normalized.message,
|
||||
failureKind: normalized.kind,
|
||||
}));
|
||||
});
|
||||
return;
|
||||
|
|
@ -286,8 +333,9 @@ export function useProjectLaunchState() {
|
|||
elapsedSeconds: 0,
|
||||
savedIdea: false,
|
||||
errorMessage: null,
|
||||
failureKind: null,
|
||||
}));
|
||||
}, [snapshot.dataSourceMode, snapshot.input.importedMaterial, snapshot.input.text]);
|
||||
}, [snapshot.dataSourceMode, snapshot.input]);
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
setSnapshot((current) => ({ ...current, paused: !current.paused }));
|
||||
|
|
@ -346,6 +394,8 @@ export function useProjectLaunchState() {
|
|||
paused: false,
|
||||
activeStepIndex: Math.min(current.activeStepIndex, 2),
|
||||
visibleOperationCount: Math.max(current.visibleOperationCount, 1),
|
||||
errorMessage: "AI 未能完成生成,输入已保留。",
|
||||
failureKind: "ai_generation_failed",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
|
|
@ -361,13 +411,29 @@ export function useProjectLaunchState() {
|
|||
const createProject = useCallback(() => {
|
||||
if (snapshot.dataSourceMode === "api") {
|
||||
if (!snapshot.draftId) {
|
||||
setSnapshot((current) => ({ ...current, stage: "generation_failed", errorMessage: "缺少可确认的项目草案,请重新生成。" }));
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
stage: "generation_failed",
|
||||
errorMessage: "缺少可确认的项目草案,请重新生成。",
|
||||
failureKind: "input_invalid",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!getAuthToken()) {
|
||||
saveProjectLaunchInput(snapshot.input);
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
stage: "generation_failed",
|
||||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续创建项目。",
|
||||
failureKind: "auth_required",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
systemStatus: { ...current.systemStatus, message: "正在创建真实项目", updatedAt: "刚刚" },
|
||||
errorMessage: null,
|
||||
failureKind: null,
|
||||
}));
|
||||
void confirmPMFlowLaunchDraft(snapshot.draftId, {
|
||||
edited_draft: mapProjectDraftToEditedPayload(snapshot.draft, snapshot.rawDraft),
|
||||
|
|
@ -386,10 +452,15 @@ export function useProjectLaunchState() {
|
|||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
const normalized = normalizeApiError(error, "system_failed");
|
||||
if (normalized.kind === "auth_required" || normalized.kind === "session_expired") {
|
||||
saveProjectLaunchInput(snapshot.input);
|
||||
}
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
stage: "generation_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "项目创建失败",
|
||||
errorMessage: normalized.message,
|
||||
failureKind: normalized.kind,
|
||||
}));
|
||||
});
|
||||
return;
|
||||
|
|
@ -401,7 +472,7 @@ export function useProjectLaunchState() {
|
|||
visibleOperationCount: aiOperationItemsMock.length,
|
||||
paused: false,
|
||||
}));
|
||||
}, [snapshot.dataSourceMode, snapshot.draft, snapshot.draftId, snapshot.rawDraft]);
|
||||
}, [snapshot.dataSourceMode, snapshot.draft, snapshot.draftId, snapshot.input, snapshot.rawDraft]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ai-routing/AIRoutingConsolePage";
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./inbox/InboxPage";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ai-routing/TeamAIPolicyPage";
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { devSyncApi } from "@/api/devSync";
|
||||
import AIConfigPage from "@/pages/AIConfigPage";
|
||||
import { GOVERNANCE_TOOL_GROUPS, GOVERNANCE_TOOLS } from "@/pages/ai-config/constants";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
vi.mock("@/api/devSync", () => ({
|
||||
devSyncApi: {
|
||||
getGithubInstallations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AIConfigPage information architecture", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({
|
||||
token: "test-token",
|
||||
user: { id: "7", username: "owner", role: "OWNER" },
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
}),
|
||||
);
|
||||
vi.mocked(devSyncApi.getGithubInstallations).mockResolvedValue({
|
||||
configured: false,
|
||||
install_url: "",
|
||||
callback_url: "",
|
||||
webhook_signature_configured: false,
|
||||
app_slug: "",
|
||||
installations: [],
|
||||
recent_deliveries: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({ token: null, user: null });
|
||||
});
|
||||
|
||||
it("presents AI Config as a team-level AI operations center", async () => {
|
||||
renderWithProviders(<AIConfigPage />, ["/ai-config"]);
|
||||
|
||||
expect(await screen.findByText("AI 运营中心")).toBeInTheDocument();
|
||||
expect(screen.getByText(/这些配置是团队级配置,会影响当前团队空间内所有项目/)).toBeInTheDocument();
|
||||
expect(await screen.findByText("AI 运营分区")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("策略").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("连接").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("治理").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/默认折叠/)).toBeInTheDocument();
|
||||
expect(screen.getByText("高级路由详情")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps grouped governance constants decision-complete", () => {
|
||||
expect(GOVERNANCE_TOOLS.some((item) => item.id === "model-routing" && item.name === "高级路由详情")).toBe(true);
|
||||
expect(GOVERNANCE_TOOL_GROUPS.map((group) => group.id)).toEqual(["strategy", "connection", "governance", "advanced"]);
|
||||
expect(GOVERNANCE_TOOL_GROUPS.find((group) => group.id === "advanced")?.primary).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Settings as SettingsIcon, CheckCircle2, AlertCircle, Sparkles
|
||||
AlertCircle, CheckCircle2, ShieldCheck, Sparkles
|
||||
} from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import { devSyncApi } from "@/api/devSync";
|
||||
|
|
@ -32,6 +32,7 @@ const OverviewTab = lazy(() => import("./sections/OverviewTab"));
|
|||
|
||||
export default function AIConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
|
|
@ -585,6 +586,13 @@ export default function AIConfigPage() {
|
|||
loadStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedTab = searchParams.get("tab");
|
||||
if (requestedTab && ["overview", "configure", "knowledge", "advanced", "prompts"].includes(requestedTab)) {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "knowledge") return;
|
||||
loadKnowledgeDocs();
|
||||
|
|
@ -686,18 +694,44 @@ export default function AIConfigPage() {
|
|||
// ==================== Render ====================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="min-h-screen bg-[#f7f9fd] p-3 dark:bg-slate-950 sm:p-4 md:p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<SettingsIcon className="w-10 h-10 text-violet-500" />
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
AI 智能配置中心
|
||||
</h1>
|
||||
<div className="mb-6 rounded-3xl border border-blue-100 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-900/35 dark:text-blue-200">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
团队级 AI 运营
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-950 dark:text-slate-100">
|
||||
AI 运营中心
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||
管理团队 AI 策略、供应商连接、路由诊断和语义治理;这里不是项目级配置页。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-xs text-slate-500 sm:grid-cols-3 lg:w-[420px]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="font-semibold text-slate-900 dark:text-slate-100">策略</div>
|
||||
<div className="mt-1">速度 / 质量 / 成本</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="font-semibold text-slate-900 dark:text-slate-100">连接</div>
|
||||
<div className="mt-1">供应商 / 工具</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="font-semibold text-slate-900 dark:text-slate-100">治理</div>
|
||||
<div className="mt-1">诊断 / 审计</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
AI 自动识别可配置的集成源,一键启用主动捕获能力
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/50 dark:text-blue-100">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
这些配置是团队级配置,会影响当前团队空间内所有项目;项目内只展示 AI 处理模式和可信状态,不展示底层模型参数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -708,7 +742,7 @@ export default function AIConfigPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-violet-500 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-violet-900 dark:text-violet-100">AI 建议配置方案</h3>
|
||||
<h3 className="font-semibold text-violet-900 dark:text-violet-100">AI 运营建议</h3>
|
||||
<p className="text-sm text-violet-700 dark:text-violet-300 mt-1">{suggestion.reasoning}</p>
|
||||
<p className="text-xs text-violet-600 dark:text-violet-400 mt-2">预期影响: {suggestion.estimated_impact}</p>
|
||||
</div>
|
||||
|
|
@ -740,9 +774,9 @@ export default function AIConfigPage() {
|
|||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-2 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">集成源概览</TabsTrigger>
|
||||
<TabsTrigger value="configure" className="shrink-0 whitespace-nowrap">快速配置</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="shrink-0 whitespace-nowrap">知识库</TabsTrigger>
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">运行总览</TabsTrigger>
|
||||
<TabsTrigger value="configure" className="shrink-0 whitespace-nowrap">连接配置</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="shrink-0 whitespace-nowrap">知识治理</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="shrink-0 whitespace-nowrap">高级设置</TabsTrigger>
|
||||
<TabsTrigger value="prompts" className="shrink-0 whitespace-nowrap">Prompt 设置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,72 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import type { ComponentType } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { GOVERNANCE_TOOLS } from "../constants";
|
||||
import { GOVERNANCE_TOOL_GROUPS } from "../constants";
|
||||
|
||||
function ToolLink({
|
||||
name,
|
||||
description,
|
||||
to,
|
||||
icon: Icon,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
to: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-3 transition-all hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800"
|
||||
>
|
||||
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{name}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">{description}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GovernanceToolsCard() {
|
||||
const primaryGroups = GOVERNANCE_TOOL_GROUPS.filter((group) => group.primary);
|
||||
const advancedGroup = GOVERNANCE_TOOL_GROUPS.find((group) => !group.primary);
|
||||
|
||||
return (
|
||||
<Card className="mb-6 border-slate-200/80 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">配置治理工具</CardTitle>
|
||||
<CardDescription>新增治理页面统一收敛到智能配置中心入口,避免侧栏信息过载。</CardDescription>
|
||||
<CardTitle className="text-lg">AI 运营分区</CardTitle>
|
||||
<CardDescription>按策略、连接、治理和高级能力组织团队级 AI 运行入口。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
|
||||
{GOVERNANCE_TOOLS.map(({ id, name, description, to, icon: Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={to}
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-3 transition-all hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800"
|
||||
>
|
||||
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{primaryGroups.map((group) => (
|
||||
<section key={group.id} className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-950/40">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-slate-950 dark:text-slate-100">{group.title}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">{group.description}</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{name}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">{description}</p>
|
||||
</Link>
|
||||
<div className="grid gap-3">
|
||||
{group.tools.map((tool) => (
|
||||
<ToolLink key={tool.id} {...tool} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{advancedGroup ? (
|
||||
<details className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4 dark:border-slate-700 dark:bg-slate-950/40">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{advancedGroup.title} · 默认折叠
|
||||
<span className="ml-2 text-xs font-normal text-slate-500">{advancedGroup.description}</span>
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{advancedGroup.tools.map((tool) => (
|
||||
<ToolLink key={tool.id} {...tool} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
History,
|
||||
LayoutTemplate,
|
||||
MessageSquare,
|
||||
Route,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
|
|
@ -52,6 +53,20 @@ export const GOVERNANCE_TOOLS = [
|
|||
to: "/ai-config/query-route-governance",
|
||||
icon: Waypoints,
|
||||
},
|
||||
{
|
||||
id: "team-ai-policy",
|
||||
name: "团队 AI 策略",
|
||||
description: "负责人管理质量、速度、成本和确认边界",
|
||||
to: "/ai-config/team-ai-policy",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
id: "ai-routing-console",
|
||||
name: "AI 路由与供应商控制台",
|
||||
description: "查看供应商、槽位、诊断和场景路由",
|
||||
to: "/ai-config/ai-routing-console",
|
||||
icon: Route,
|
||||
},
|
||||
{
|
||||
id: "mcp-config",
|
||||
name: "MCP 模板接入中心",
|
||||
|
|
@ -61,15 +76,15 @@ export const GOVERNANCE_TOOLS = [
|
|||
},
|
||||
{
|
||||
id: "model-routing",
|
||||
name: "模型路由配置",
|
||||
description: "动态切换函数模型路由",
|
||||
name: "高级路由详情",
|
||||
description: "按函数查看快速/深度槽位覆盖",
|
||||
to: "/ai-config/model-routing-config",
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
id: "knowledge-graph-board",
|
||||
name: "知识图谱上下文看板",
|
||||
description: "查看 KG 汇总与 low/high 上下文消费验证",
|
||||
description: "查看 KG 汇总与上下文消费验证",
|
||||
to: "/ai-config/knowledge-graph-board",
|
||||
icon: Boxes,
|
||||
},
|
||||
|
|
@ -82,6 +97,70 @@ export const GOVERNANCE_TOOLS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
type GovernanceTool = (typeof GOVERNANCE_TOOLS)[number];
|
||||
|
||||
const toolById = Object.fromEntries(GOVERNANCE_TOOLS.map((item) => [item.id, item])) as Record<GovernanceTool["id"], GovernanceTool>;
|
||||
|
||||
export const GOVERNANCE_TOOL_GROUPS = [
|
||||
{
|
||||
id: "strategy",
|
||||
title: "策略",
|
||||
description: "负责人管理团队如何使用 AI,而不是直接碰底层模型参数。",
|
||||
primary: true,
|
||||
tools: [
|
||||
toolById["team-ai-policy"],
|
||||
{
|
||||
id: "scene-routing",
|
||||
name: "场景路由",
|
||||
description: "按业务场景查看快速处理、深度分析和保护边界",
|
||||
to: "/ai-config/ai-routing-console",
|
||||
icon: Waypoints,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "connection",
|
||||
title: "连接",
|
||||
description: "AI 供应商、工具接入和消息入口的团队级运行状态。",
|
||||
primary: true,
|
||||
tools: [
|
||||
toolById["ai-routing-console"],
|
||||
toolById["mcp-config"],
|
||||
{
|
||||
id: "integration-config",
|
||||
name: "飞书 / GitHub / 企业微信",
|
||||
description: "配置团队消息、仓库和外部捕获入口",
|
||||
to: "/ai-config?tab=configure",
|
||||
icon: Plug,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "governance",
|
||||
title: "治理",
|
||||
description: "排查问答路由、语义事实、知识图谱和任务包质量。",
|
||||
primary: true,
|
||||
tools: [
|
||||
toolById["query-route-governance"],
|
||||
toolById["semantic-facts-board"],
|
||||
toolById["knowledge-graph-board"],
|
||||
toolById["brief-center"],
|
||||
toolById["brief-template"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
title: "高级",
|
||||
description: "给 OWNER / AI 管理员使用的低频调试和审计入口。",
|
||||
primary: false,
|
||||
tools: [
|
||||
toolById["model-routing"],
|
||||
toolById["contract-board"],
|
||||
toolById["brief-runs"],
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const buildIntegrationSources = (status: ConfigStatus | null): IntegrationSource[] => [
|
||||
{
|
||||
id: "feishu",
|
||||
|
|
|
|||
|
|
@ -139,9 +139,9 @@ describe("MCPConfigPage", () => {
|
|||
|
||||
it("exposes governance entry and route", () => {
|
||||
expect(GOVERNANCE_TOOLS.some((item) => item.id === "mcp-config" && item.to === "/ai-config/mcp-config")).toBe(true);
|
||||
const aiConfigRoute = router.routes.find((route) => route.path === "/");
|
||||
const children = aiConfigRoute?.children || [];
|
||||
expect(children.some((route) => route.path === "ai-config/mcp-config")).toBe(true);
|
||||
const collectPaths = (routes: typeof router.routes): string[] =>
|
||||
routes.flatMap((route) => [String(route.path || ""), ...collectPaths(route.children || [])]);
|
||||
expect(collectPaths(router.routes).includes("ai-config/mcp-config")).toBe(true);
|
||||
});
|
||||
|
||||
it("switches template fields based on selected template", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CheckCircle2, Cpu, Eye, EyeOff, Gauge, ShieldAlert, Waypoints } from "lucide-react";
|
||||
|
||||
import { aiRoutingApi, type AIRoutingDiagnostic } from "@/api/aiRouting";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canOperateAiRouting, canViewAiPolicy } from "@/lib/rbac";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
function severityClass(severity: AIRoutingDiagnostic["severity"]): string {
|
||||
if (severity === "critical") return "border-rose-200 bg-rose-50 text-rose-800";
|
||||
if (severity === "warning") return "border-amber-200 bg-amber-50 text-amber-800";
|
||||
return "border-blue-200 bg-blue-50 text-blue-800";
|
||||
}
|
||||
|
||||
function ratioText(value: number): string {
|
||||
return `${Math.round((Number(value) || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function PermissionNote() {
|
||||
return (
|
||||
<Card className="border-amber-200 bg-amber-50/80 text-amber-900">
|
||||
<CardContent className="pt-6 text-sm">仅 `OWNER / REVIEWER` 可访问 AI 路由与供应商控制台。</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIRoutingConsolePage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canView = canViewAiPolicy(user?.role);
|
||||
const canOperate = canOperateAiRouting(user?.role);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const consoleQuery = useQuery({
|
||||
queryKey: ["ai-routing", "admin-console"],
|
||||
queryFn: () => aiRoutingApi.getAdminConsole(),
|
||||
enabled: canView,
|
||||
});
|
||||
|
||||
const diagnosticsMutation = useMutation({
|
||||
mutationFn: () => aiRoutingApi.runDiagnostics(),
|
||||
});
|
||||
|
||||
if (!canView) {
|
||||
return <PermissionNote />;
|
||||
}
|
||||
|
||||
const data = consoleQuery.data;
|
||||
const overview = data?.overview;
|
||||
const diagnostics = diagnosticsMutation.data?.items || data?.diagnostics || [];
|
||||
const criticalCount = diagnostics.filter((item) => item.severity === "critical").length;
|
||||
const warningCount = diagnostics.filter((item) => item.severity === "warning").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="AI 路由与供应商控制台"
|
||||
description="管理员查看供应商、快速/深度槽位、业务场景路由和运行风险;底层模型默认值仍由 PMFlow 本地路由层管理。"
|
||||
icon={<Waypoints className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={<span>{overview ? `路由版本 ${overview.config_version || "-"}` : "读取中"}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="ai-routing-console"
|
||||
title="AI 路由与供应商控制台"
|
||||
purpose="把底层模型配置转成可诊断、可审计的 AI 运行控制台。"
|
||||
whenToUse="当 AI 失败率升高、成本异常、模型 Key 变更或需要核对业务场景路由时使用。"
|
||||
steps={["查看槽位和风险诊断。", "核对供应商连接状态。", "必要时展开高级详情定位函数级路由。"]}
|
||||
outputs={["供应商状态", "快速/深度槽位", "风险诊断", "场景路由详情"]}
|
||||
/>
|
||||
|
||||
{consoleQuery.isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-slate-500">加载 AI 路由控制台...</CardContent>
|
||||
</Card>
|
||||
) : consoleQuery.isError || !data || !overview ? (
|
||||
<Card className="border-rose-200 bg-rose-50/80">
|
||||
<CardContent className="pt-6 text-sm text-rose-700">AI 路由控制台读取失败,请稍后重试。</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Gauge className="h-4 w-4 text-blue-600" />
|
||||
24 小时调用
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">{overview.metrics.last_24h.total_calls}</div>
|
||||
<p className="text-xs text-slate-500">失败率 {ratioText(overview.metrics.last_24h.failure_rate)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">升级率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">{ratioText(overview.metrics.last_24h.upgrade_rate)}</div>
|
||||
<p className="text-xs text-slate-500">低阶失败后进入深度处理</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">结构化失败</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">{overview.metrics.last_24h.structured_failed_calls}</div>
|
||||
<p className="text-xs text-slate-500">项目启动、任务拆解重点关注</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">风险</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">{criticalCount + warningCount}</div>
|
||||
<p className="text-xs text-slate-500">{criticalCount} 个严重,{warningCount} 个警告</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr,380px]">
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">快速槽 / 深度槽</CardTitle>
|
||||
<CardDescription>业务层只使用槽位语义;底层 provider 和 model 只在管理员控制台展示。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
{overview.slots.map((slot) => (
|
||||
<div key={slot.tier} className="rounded-xl border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-950">{slot.label}</h3>
|
||||
<p className="mt-1 text-sm text-slate-600">{slot.provider_label}</p>
|
||||
</div>
|
||||
<Badge className={slot.configured ? "bg-blue-600 text-white" : "bg-rose-600 text-white"}>
|
||||
{slot.configured ? "可用" : "未配置"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-xs text-slate-500">
|
||||
<div>模型:{slot.model || "-"}</div>
|
||||
<div>来源:{slot.config_source}</div>
|
||||
<div>密钥:{slot.api_key_masked || slot.config_source}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlert className="h-4 w-4 text-blue-600" />
|
||||
风险诊断
|
||||
</CardTitle>
|
||||
<CardDescription>诊断只读执行,不改配置。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!canOperate || diagnosticsMutation.isPending}
|
||||
onClick={() => diagnosticsMutation.mutate()}
|
||||
>
|
||||
{diagnosticsMutation.isPending ? "诊断中..." : "重新诊断"}
|
||||
</Button>
|
||||
{diagnostics.map((item) => (
|
||||
<div key={`${item.code}-${item.title}`} className={`rounded-xl border p-3 ${severityClass(item.severity)}`}>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
{item.severity === "ok" ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
|
||||
{item.title}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5">{item.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">供应商连接</CardTitle>
|
||||
<CardDescription>供应商 CRUD 仍复用现有 LLM 引擎接口;这里做脱敏聚合展示。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">密钥永远脱敏</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.providers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无供应商配置。</p>
|
||||
) : (
|
||||
data.providers.map((provider) => (
|
||||
<div key={provider.id} className="rounded-xl border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-950">{provider.name}</h3>
|
||||
<p className="text-sm text-slate-600">{provider.provider_label}</p>
|
||||
</div>
|
||||
{provider.active ? <Badge className="bg-blue-600 text-white">激活</Badge> : <Badge variant="outline">已配置</Badge>}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-xs text-slate-500">
|
||||
<div>模型:{provider.model || "-"}</div>
|
||||
<div>基础地址:{provider.base_url || "-"}</div>
|
||||
<div>密钥:{provider.api_key_masked || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">业务场景路由</CardTitle>
|
||||
<CardDescription>默认按业务场景展示,函数名只在高级详情里出现。</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdvanced((value) => !value)}>
|
||||
{showAdvanced ? <EyeOff className="mr-1 h-4 w-4" /> : <Eye className="mr-1 h-4 w-4" />}
|
||||
{showAdvanced ? "隐藏高级详情" : "显示高级详情"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.scene_routes.map((route) => (
|
||||
<div key={route.scene_key} className="rounded-xl border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-950">{route.label}</h3>
|
||||
<p className="mt-1 text-sm text-slate-600">{route.summary}</p>
|
||||
</div>
|
||||
<Badge className="bg-blue-600 text-white">{route.primary_slot_label}</Badge>
|
||||
</div>
|
||||
{showAdvanced ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{route.functions.map((item) => (
|
||||
<div key={item.function_name} className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs">
|
||||
<div className="font-medium text-slate-800">{item.display_name}</div>
|
||||
<div className="mt-1 text-slate-500">{item.function_name}</div>
|
||||
<div className="mt-1 text-slate-500">{item.slot_label} · {item.source === "override" ? "租户覆盖" : "默认规则"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cpu className="h-4 w-4 text-blue-600" />
|
||||
最近失败样本
|
||||
</CardTitle>
|
||||
<CardDescription>只展示产品化状态,不展示供应商原始错误。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.recent_failures.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无失败样本。</p>
|
||||
) : (
|
||||
data.recent_failures.map((item) => (
|
||||
<div key={`${item.function_name}-${item.created_at}`} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50/70 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{item.function_label}</div>
|
||||
<div className="text-xs text-slate-500">{item.planned_slot}{" -> "}{item.executed_slot}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{item.status_label}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<p className="pt-2 text-xs text-slate-500">{data.audit_hint}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { aiRoutingApi, type AIRoutingAdminConsole, type AIRoutingOverview, type AITeamPolicy } from "@/api/aiRouting";
|
||||
import { GOVERNANCE_TOOLS } from "@/pages/ai-config/constants";
|
||||
import AIRoutingConsolePage from "@/pages/AIRoutingConsolePage";
|
||||
import TeamAIPolicyPage from "@/pages/TeamAIPolicyPage";
|
||||
import { router } from "@/router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
vi.mock("@/api/aiRouting", () => ({
|
||||
aiRoutingApi: {
|
||||
getOverview: vi.fn(),
|
||||
getTeamPolicy: vi.fn(),
|
||||
updateTeamPolicy: vi.fn(),
|
||||
getAdminConsole: vi.fn(),
|
||||
runDiagnostics: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ai-config/FeatureIntroGuide", () => ({
|
||||
FeatureIntroGuide: () => <div data-testid="feature-guide">feature-guide</div>,
|
||||
}));
|
||||
|
||||
const overview: AIRoutingOverview = {
|
||||
config_version: "v3",
|
||||
config_updated_at: "2026-03-26T21:30:00+08:00",
|
||||
source_of_truth: "PMFlow 本地路由层 baseline",
|
||||
slots: [
|
||||
{
|
||||
tier: "low",
|
||||
label: "快速槽",
|
||||
provider: "glm",
|
||||
provider_label: "Qwen / DashScope",
|
||||
model: "qwen-plus",
|
||||
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
configured: true,
|
||||
config_source: "租户配置",
|
||||
api_key_masked: "db-l...-key",
|
||||
},
|
||||
{
|
||||
tier: "high",
|
||||
label: "深度槽",
|
||||
provider: "openai",
|
||||
provider_label: "OpenAI 兼容网关",
|
||||
model: "GLM-5",
|
||||
base_url: "https://api.edgefn.net/v1",
|
||||
configured: true,
|
||||
config_source: "租户配置",
|
||||
api_key_masked: "db-h...-key",
|
||||
},
|
||||
],
|
||||
metrics: {
|
||||
last_24h: {
|
||||
total_calls: 12,
|
||||
failed_calls: 3,
|
||||
failure_rate: 0.25,
|
||||
upgraded_calls: 2,
|
||||
upgrade_rate: 0.1667,
|
||||
structured_failed_calls: 2,
|
||||
estimated_cost: 0.12,
|
||||
average_latency_ms: null,
|
||||
},
|
||||
last_7d: {
|
||||
total_calls: 40,
|
||||
failed_calls: 4,
|
||||
failure_rate: 0.1,
|
||||
upgraded_calls: 6,
|
||||
upgrade_rate: 0.15,
|
||||
structured_failed_calls: 3,
|
||||
estimated_cost: 0.5,
|
||||
average_latency_ms: null,
|
||||
},
|
||||
},
|
||||
diagnostics: [
|
||||
{
|
||||
code: "env_key_override",
|
||||
severity: "critical",
|
||||
title: "Qwen / DashScope 环境变量正在覆盖租户密钥",
|
||||
message: "运行时会优先使用环境变量中的密钥。",
|
||||
},
|
||||
],
|
||||
latency_status: "暂未采集平均耗时",
|
||||
};
|
||||
|
||||
const teamPolicy: AITeamPolicy = {
|
||||
strategy: "balanced",
|
||||
strategy_label: "成本均衡",
|
||||
scenes: [
|
||||
{
|
||||
key: "project_launch",
|
||||
label: "项目启动草案",
|
||||
policy: "快速起草,失败后深度修复,结果必须进入确认",
|
||||
mode: "快速起草,失败升级",
|
||||
human_confirmation: true,
|
||||
protected: true,
|
||||
},
|
||||
],
|
||||
automation_boundary: "高风险写操作只生成待确认草案,禁止模型直接改生产状态。",
|
||||
budget_guard: {
|
||||
enabled: false,
|
||||
monthly_budget_cny: null,
|
||||
alert_threshold_ratio: 0.8,
|
||||
},
|
||||
quality_targets: [
|
||||
"项目启动、需求结构化、任务拆解失败时必须显式进入待确认或失败态",
|
||||
],
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
};
|
||||
|
||||
const adminConsole: AIRoutingAdminConsole = {
|
||||
overview,
|
||||
providers: [
|
||||
{
|
||||
id: "low-1",
|
||||
name: "Qwen 低阶引擎",
|
||||
provider: "glm",
|
||||
provider_label: "Qwen / DashScope",
|
||||
model: "qwen-plus",
|
||||
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
api_key_masked: "db-l...-key",
|
||||
active: true,
|
||||
status_label: "已启用",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
scene_routes: [
|
||||
{
|
||||
scene_key: "project_launch",
|
||||
label: "项目启动草案",
|
||||
summary: "快速起草,失败后深度修复,结果必须进入确认",
|
||||
primary_slot_label: "快速槽",
|
||||
functions: [
|
||||
{
|
||||
function_name: "pmflow_launch_draft_v1",
|
||||
display_name: "项目启动草案",
|
||||
tier: "low",
|
||||
slot_label: "快速槽",
|
||||
source: "default",
|
||||
rule_name: "engine_low_fallback",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
protected_functions: ["pmflow_launch_draft_v1"],
|
||||
recent_failures: [
|
||||
{
|
||||
function_label: "项目启动草案",
|
||||
function_name: "pmflow_launch_draft_v1",
|
||||
status_label: "模型返回为空",
|
||||
planned_slot: "快速槽",
|
||||
executed_slot: "快速槽",
|
||||
created_at: "2026-04-27T00:00:00",
|
||||
},
|
||||
],
|
||||
diagnostics: overview.diagnostics,
|
||||
audit_hint: "策略变更会写入操作日志。",
|
||||
};
|
||||
|
||||
describe("AI routing productized pages", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({
|
||||
token: "test-token",
|
||||
user: {
|
||||
id: "7",
|
||||
username: "boss",
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
vi.mocked(aiRoutingApi.getTeamPolicy).mockResolvedValue(teamPolicy);
|
||||
vi.mocked(aiRoutingApi.updateTeamPolicy).mockResolvedValue({ ...teamPolicy, strategy: "quality_first", strategy_label: "质量优先" });
|
||||
vi.mocked(aiRoutingApi.getAdminConsole).mockResolvedValue(adminConsole);
|
||||
vi.mocked(aiRoutingApi.runDiagnostics).mockResolvedValue({
|
||||
checked_at: "2026-04-27T00:00:00",
|
||||
items: overview.diagnostics,
|
||||
summary: { critical: 1, warning: 0, ok: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes new governance entries and routes", () => {
|
||||
expect(GOVERNANCE_TOOLS.some((item) => item.id === "team-ai-policy" && item.to === "/ai-config/team-ai-policy")).toBe(true);
|
||||
expect(GOVERNANCE_TOOLS.some((item) => item.id === "ai-routing-console" && item.to === "/ai-config/ai-routing-console")).toBe(true);
|
||||
const collectPaths = (routes: typeof router.routes): string[] =>
|
||||
routes.flatMap((route) => [String(route.path || ""), ...collectPaths(route.children || [])]);
|
||||
const paths = collectPaths(router.routes);
|
||||
expect(paths.includes("ai-config/team-ai-policy")).toBe(true);
|
||||
expect(paths.includes("ai-config/ai-routing-console")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders team policy and lets owner save strategy", async () => {
|
||||
renderWithProviders(<TeamAIPolicyPage />);
|
||||
|
||||
expect(await screen.findByText("项目启动草案")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /质量优先/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存团队策略" }));
|
||||
|
||||
await waitFor(() => expect(aiRoutingApi.updateTeamPolicy).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("keeps reviewer read-only on team policy", async () => {
|
||||
useAuthStore.setState({
|
||||
token: "test-token",
|
||||
user: {
|
||||
id: "8",
|
||||
username: "reviewer",
|
||||
role: "REVIEWER",
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<TeamAIPolicyPage />);
|
||||
|
||||
expect(await screen.findByText("项目启动草案")).not.toBeNull();
|
||||
expect(await screen.findByRole("button", { name: "保存团队策略" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders admin console and hides function names until advanced details are opened", async () => {
|
||||
renderWithProviders(<AIRoutingConsolePage />);
|
||||
|
||||
expect(await screen.findByText("Qwen / DashScope 环境变量正在覆盖租户密钥")).not.toBeNull();
|
||||
expect((await screen.findAllByText("快速槽")).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("pmflow_launch_draft_v1")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "显示高级详情" }));
|
||||
|
||||
expect(await screen.findByText("pmflow_launch_draft_v1")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows manager-only fallback for member users", async () => {
|
||||
useAuthStore.setState({
|
||||
token: "test-token",
|
||||
user: {
|
||||
id: "9",
|
||||
username: "member",
|
||||
role: "BUILDER",
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<AIRoutingConsolePage />);
|
||||
|
||||
expect(await screen.findByText(/仅 `OWNER \/ REVIEWER` 可访问 AI 路由与供应商控制台/)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BrainCircuit, CheckCircle2, Gauge, ShieldCheck, Zap } from "lucide-react";
|
||||
|
||||
import { aiRoutingApi, type AIRoutingStrategy, type AITeamPolicy } from "@/api/aiRouting";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { canApproveAiRoutingChange, canViewAiPolicy } from "@/lib/rbac";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const STRATEGY_OPTIONS: Array<{ value: AIRoutingStrategy; label: string; description: string }> = [
|
||||
{ value: "balanced", label: "成本均衡", description: "多数流程快速起草,关键失败自动升级。" },
|
||||
{ value: "quality_first", label: "质量优先", description: "项目真相层与复杂判断更偏深度分析。" },
|
||||
{ value: "speed_first", label: "速度优先", description: "轻量处理优先,风险流程仍保留确认。" },
|
||||
];
|
||||
|
||||
function PermissionNote() {
|
||||
return (
|
||||
<Card className="border-amber-200 bg-amber-50/80 text-amber-900">
|
||||
<CardContent className="pt-6 text-sm">仅 `OWNER / REVIEWER` 可访问团队 AI 策略。</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamAIPolicyPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canView = canViewAiPolicy(user?.role);
|
||||
const canSave = canApproveAiRoutingChange(user?.role);
|
||||
const [draft, setDraft] = useState<AITeamPolicy | null>(null);
|
||||
|
||||
const policyQuery = useQuery({
|
||||
queryKey: ["ai-routing", "team-policy"],
|
||||
queryFn: () => aiRoutingApi.getTeamPolicy(),
|
||||
enabled: canView,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (policyQuery.data) {
|
||||
setDraft(policyQuery.data);
|
||||
}
|
||||
}, [policyQuery.data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: AITeamPolicy) =>
|
||||
aiRoutingApi.updateTeamPolicy({
|
||||
strategy: payload.strategy,
|
||||
scenes: payload.scenes,
|
||||
automation_boundary: payload.automation_boundary,
|
||||
budget_guard: payload.budget_guard,
|
||||
quality_targets: payload.quality_targets,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setDraft(data);
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-routing", "team-policy"] });
|
||||
},
|
||||
});
|
||||
|
||||
const strategyLabel = useMemo(
|
||||
() => STRATEGY_OPTIONS.find((item) => item.value === draft?.strategy)?.label || "成本均衡",
|
||||
[draft?.strategy],
|
||||
);
|
||||
|
||||
if (!canView) {
|
||||
return <PermissionNote />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="团队 AI 策略"
|
||||
description="负责人管理团队层面的速度、质量、成本和自动化边界,不直接暴露底层模型参数。"
|
||||
icon={<BrainCircuit className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={<span>{draft?.updated_at ? `上次更新 ${draft.updated_at}` : "使用默认策略"}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="team-ai-policy"
|
||||
title="团队 AI 策略"
|
||||
purpose="把模型路由翻译成团队可理解的 AI 工作策略。"
|
||||
whenToUse="当团队需要调整质量、成本、速度或确认边界时使用。"
|
||||
steps={["选择团队默认策略。", "核对各业务场景的处理模式。", "由 OWNER 保存后进入审计。"]}
|
||||
outputs={["团队 AI 策略", "场景级处理边界", "可审计的策略变更"]}
|
||||
/>
|
||||
|
||||
{policyQuery.isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-slate-500">加载团队 AI 策略...</CardContent>
|
||||
</Card>
|
||||
) : policyQuery.isError || !draft ? (
|
||||
<Card className="border-rose-200 bg-rose-50/80">
|
||||
<CardContent className="pt-6 text-sm text-rose-700">团队 AI 策略读取失败,请稍后重试。</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr,360px]">
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">默认策略</CardTitle>
|
||||
<CardDescription>这是负责人看到和管理的团队策略,不是底层模型参数选择器。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
{STRATEGY_OPTIONS.map((item) => {
|
||||
const active = draft.strategy === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`rounded-xl border p-4 text-left transition ${
|
||||
active
|
||||
? "border-blue-500 bg-blue-50 text-blue-950"
|
||||
: "border-slate-200 bg-white text-slate-700 hover:border-blue-200 hover:bg-blue-50/40"
|
||||
}`}
|
||||
onClick={() => setDraft({ ...draft, strategy: item.value, strategy_label: item.label })}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
{active ? <CheckCircle2 className="h-4 w-4 text-blue-600" /> : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">{item.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-blue-200 bg-blue-50/70 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Gauge className="h-4 w-4 text-blue-600" />
|
||||
当前策略
|
||||
</CardTitle>
|
||||
<CardDescription>{strategyLabel}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<span>{draft.automation_boundary}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<span>预算告警:{draft.budget_guard.enabled ? "已启用" : "未启用"}</span>
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-blue-200 pt-3">
|
||||
<div className="text-xs font-semibold text-blue-900">质量目标</div>
|
||||
{draft.quality_targets.map((item) => (
|
||||
<div key={item} className="text-xs leading-5 text-slate-600">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">业务场景策略</CardTitle>
|
||||
<CardDescription>成员看到的是处理模式和可信状态;这里管理每个业务场景的策略语义。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{draft.scenes.map((scene, index) => (
|
||||
<div key={scene.key} className="rounded-xl border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold text-slate-950">{scene.label}</h3>
|
||||
<Badge variant="outline">{scene.mode}</Badge>
|
||||
{scene.protected ? <Badge className="bg-blue-600 text-white">保护场景</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{scene.human_confirmation ? "关键产物需要人工确认" : "低风险流程可自动处理"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
className="mt-3 min-h-[76px] bg-white"
|
||||
value={scene.policy}
|
||||
onChange={(event) => {
|
||||
const nextScenes = [...draft.scenes];
|
||||
nextScenes[index] = { ...scene, policy: event.target.value };
|
||||
setDraft({ ...draft, scenes: nextScenes });
|
||||
}}
|
||||
disabled={!canSave}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">自动化边界</CardTitle>
|
||||
<CardDescription>这里决定 AI 什么时候必须停在待确认,不直接写生产状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
className="min-h-[88px]"
|
||||
value={draft.automation_boundary}
|
||||
onChange={(event) => setDraft({ ...draft, automation_boundary: event.target.value })}
|
||||
disabled={!canSave}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">
|
||||
{canSave ? "保存后会写入操作日志。" : "REVIEWER 可查看策略,但只有 OWNER 可保存。"}
|
||||
</p>
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
disabled={!canSave || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
>
|
||||
{saveMutation.isPending ? "保存中..." : "保存团队策略"}
|
||||
</Button>
|
||||
</div>
|
||||
{saveMutation.isError ? <p className="text-sm text-rose-600">保存失败,请检查权限或稍后重试。</p> : null}
|
||||
{saveMutation.isSuccess ? <p className="text-sm text-blue-600">团队 AI 策略已保存。</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,861 +1,7 @@
|
|||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { getAuthToken } from "@/lib/api";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import {
|
||||
Card, CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { SectionLoadingShell } from "@/components/layout/LazyBoundary";
|
||||
import { parseUserRoles } from "@/api/users";
|
||||
import {
|
||||
aiStewardApi,
|
||||
type LLMEngine,
|
||||
type LLMEngineUpsertRequest,
|
||||
} from "@/api/aiSteward";
|
||||
import {
|
||||
DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
ENGINE_PROVIDER_FALLBACK_PRESETS,
|
||||
} from "./constants";
|
||||
import type {
|
||||
BriefingTarget,
|
||||
Blocker,
|
||||
CognitiveConflict,
|
||||
DailyBriefingV2Payload,
|
||||
ExecutiveBriefingConfig,
|
||||
ExecutiveBriefingPayload,
|
||||
FeedbackCenterSourceGroup,
|
||||
FeedbackCenterSection,
|
||||
FeedbackCenterTrendWindowDays,
|
||||
EngineDataState,
|
||||
EngineForm,
|
||||
EngineMessage,
|
||||
EngineProviderPresets,
|
||||
ProjectFromTextResponse,
|
||||
AIStewardTab,
|
||||
} from "./types";
|
||||
import type { PrintableBriefingPayload } from "./components/BriefingPrintDocument";
|
||||
|
||||
const QAHistoryPanel = lazy(() =>
|
||||
import("@/components/ai-steward/QAHistoryPanel").then((module) => ({ default: module.QAHistoryPanel }))
|
||||
);
|
||||
const FeatureOverviewCards = lazy(() => import("./components/FeatureOverviewCards"));
|
||||
const BlockersTab = lazy(() => import("./sections/BlockersTab"));
|
||||
const ConflictsTab = lazy(() => import("./sections/ConflictsTab"));
|
||||
const CreateProjectTab = lazy(() => import("./sections/CreateProjectTab"));
|
||||
const EngineTab = lazy(() => import("./sections/EngineTab"));
|
||||
const FeedbackCenterTab = lazy(() => import("./sections/FeedbackCenterTab"));
|
||||
const OverviewTab = lazy(() => import("./sections/OverviewTab"));
|
||||
const SignalsTab = lazy(() => import("./sections/SignalsTab"));
|
||||
|
||||
const AIS_STEWARD_TABS: AIStewardTab[] = [
|
||||
"overview",
|
||||
"create",
|
||||
"signals",
|
||||
"conflicts",
|
||||
"blockers",
|
||||
"briefing",
|
||||
"qa",
|
||||
"feedback-center",
|
||||
"engine",
|
||||
];
|
||||
|
||||
const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"];
|
||||
const DEFAULT_EXECUTIVE_BRIEFING_CONFIG: ExecutiveBriefingConfig = {
|
||||
enabled: true,
|
||||
send_time: "08:30",
|
||||
send_cycle: "workdays",
|
||||
delivery_target_type: "management_group",
|
||||
delivery_target_ref: null,
|
||||
timezone: "Asia/Shanghai",
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
};
|
||||
|
||||
function normalizeTab(value: string | null): AIStewardTab {
|
||||
if (value === "briefing") {
|
||||
return "overview";
|
||||
}
|
||||
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
||||
return value as AIStewardTab;
|
||||
}
|
||||
return "overview";
|
||||
}
|
||||
|
||||
function normalizeSection(value: string | null): FeedbackCenterSection {
|
||||
if (value && FEEDBACK_SECTIONS.includes(value as FeedbackCenterSection)) {
|
||||
return value as FeedbackCenterSection;
|
||||
}
|
||||
return "overview";
|
||||
}
|
||||
|
||||
function normalizeTrendWindow(value: string | null): FeedbackCenterTrendWindowDays {
|
||||
return value === "30" ? 30 : 7;
|
||||
}
|
||||
|
||||
function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGroup {
|
||||
if (value === "task" || value === "qa" || value === "signal") {
|
||||
return value;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function serializeTargetParams(params?: BriefingTarget["params"]): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params || {})) {
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
query.set(key, String(value));
|
||||
}
|
||||
return query.toString();
|
||||
}
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { getLegacyAIStewardRedirectPath } from "@/router";
|
||||
|
||||
export default function AIStewardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "REVIEWER" || role === "OWNER");
|
||||
const canManageExecutiveBriefing = canViewFeedbackCenter;
|
||||
|
||||
// Project creation state
|
||||
const [input, setInput] = useState("");
|
||||
const [projectResult, setProjectResult] = useState<ProjectFromTextResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// AI Steward features state
|
||||
const [activeTab, setActiveTab] = useState<AIStewardTab>(() => normalizeTab(searchParams.get("tab")));
|
||||
const [feedbackSection, setFeedbackSection] = useState<FeedbackCenterSection>(() =>
|
||||
normalizeSection(searchParams.get("section"))
|
||||
);
|
||||
const [trendWindowDays, setTrendWindowDays] = useState<FeedbackCenterTrendWindowDays>(() =>
|
||||
normalizeTrendWindow(searchParams.get("window"))
|
||||
);
|
||||
const [trendSourceGroup, setTrendSourceGroup] = useState<FeedbackCenterSourceGroup>(() =>
|
||||
normalizeTrendSourceGroup(searchParams.get("scope"))
|
||||
);
|
||||
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||
const [briefingMode, setBriefingMode] = useState<"snapshot" | "live">("snapshot");
|
||||
const [executiveBriefingConfigDraft, setExecutiveBriefingConfigDraft] = useState<ExecutiveBriefingConfig>(
|
||||
DEFAULT_EXECUTIVE_BRIEFING_CONFIG
|
||||
);
|
||||
const [executiveBriefingConfigError, setExecutiveBriefingConfigError] = useState<string | null>(null);
|
||||
const [executiveBriefingSaveMessage, setExecutiveBriefingSaveMessage] = useState<EngineMessage | null>(null);
|
||||
const [executiveBriefingConfigLoading, setExecutiveBriefingConfigLoading] = useState(false);
|
||||
const [executiveBriefingConfigSaving, setExecutiveBriefingConfigSaving] = useState(false);
|
||||
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
|
||||
const [engineData, setEngineData] = useState<EngineDataState>({
|
||||
active_engine_id: null,
|
||||
engines: [],
|
||||
settings: DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
const [engineProviderPresets, setEngineProviderPresets] = useState<EngineProviderPresets>(
|
||||
ENGINE_PROVIDER_FALLBACK_PRESETS
|
||||
);
|
||||
const [engineForm, setEngineForm] = useState<EngineForm>({
|
||||
name: "",
|
||||
provider: "glm",
|
||||
api_key: "",
|
||||
model: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
|
||||
base_url: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
|
||||
});
|
||||
const [engineEditingId, setEngineEditingId] = useState<string | null>(null);
|
||||
const [engineMessage, setEngineMessage] = useState<EngineMessage | null>(null);
|
||||
|
||||
const projectCreationMutation = useMutation({
|
||||
mutationKey: ["ai-steward", "project-from-text"],
|
||||
mutationFn: async (text: string) => aiStewardApi.createProjectFromText({ input_text: text }),
|
||||
onSuccess: (data) => {
|
||||
setProjectResult(data);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setProjectResult(null);
|
||||
setError(getApiErrorMessage(err, "项目创建失败"));
|
||||
},
|
||||
});
|
||||
|
||||
const executiveBriefingQuery = useQuery<ExecutiveBriefingPayload>({
|
||||
queryKey: ["ai-steward", "executive-briefing", briefingMode],
|
||||
queryFn: () => aiStewardApi.getExecutiveBriefing(briefingMode),
|
||||
enabled: canManageExecutiveBriefing,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const dailyBriefingQuery = useQuery<DailyBriefingV2Payload>({
|
||||
queryKey: ["ai-steward", "daily-briefing-v2", briefingMode],
|
||||
queryFn: () => aiStewardApi.getDailyBriefingV2(briefingMode),
|
||||
enabled: Boolean(user) && (activeTab === "overview" || activeTab === "briefing" || canManageExecutiveBriefing),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const executiveBriefing = executiveBriefingQuery.data ?? null;
|
||||
const executiveBriefingLoading = executiveBriefingQuery.isLoading || executiveBriefingQuery.isFetching;
|
||||
const executiveBriefingError = executiveBriefingQuery.error
|
||||
? getApiErrorMessage(executiveBriefingQuery.error, "获取简报失败")
|
||||
: null;
|
||||
const dailyBriefing = dailyBriefingQuery.data ?? null;
|
||||
const dailyBriefingLoading = dailyBriefingQuery.isLoading || dailyBriefingQuery.isFetching;
|
||||
const dailyBriefingError = dailyBriefingQuery.error
|
||||
? getApiErrorMessage(dailyBriefingQuery.error, "获取每日简报失败")
|
||||
: null;
|
||||
|
||||
// ==================== API Calls ====================
|
||||
|
||||
const loadConflicts = async () => {
|
||||
setFeatureLoading("conflicts");
|
||||
try {
|
||||
const data = await aiStewardApi.listCognitiveConflicts();
|
||||
setConflicts(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load conflicts:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBlockers = async () => {
|
||||
setFeatureLoading("blockers");
|
||||
try {
|
||||
const data = await aiStewardApi.listActiveBlockers();
|
||||
setBlockers(data.blockers || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load blockers:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const switchBriefingMode = (mode: "snapshot" | "live") => {
|
||||
if (mode === briefingMode) {
|
||||
if (canManageExecutiveBriefing) {
|
||||
void executiveBriefingQuery.refetch();
|
||||
}
|
||||
if (activeTab === "briefing" || canManageExecutiveBriefing) {
|
||||
void dailyBriefingQuery.refetch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setBriefingMode(mode);
|
||||
};
|
||||
|
||||
const loadExecutiveBriefingConfig = async () => {
|
||||
setExecutiveBriefingConfigLoading(true);
|
||||
setExecutiveBriefingConfigError(null);
|
||||
try {
|
||||
const data = await aiStewardApi.getExecutiveBriefingConfig();
|
||||
setExecutiveBriefingConfigDraft(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load executive briefing config:", err);
|
||||
setExecutiveBriefingConfigError(getApiErrorMessage(err, "加载晨报配置失败"));
|
||||
} finally {
|
||||
setExecutiveBriefingConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveExecutiveBriefingConfig = async () => {
|
||||
setExecutiveBriefingConfigSaving(true);
|
||||
setExecutiveBriefingConfigError(null);
|
||||
setExecutiveBriefingSaveMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.updateExecutiveBriefingConfig(executiveBriefingConfigDraft);
|
||||
setExecutiveBriefingConfigDraft(data);
|
||||
setExecutiveBriefingSaveMessage({ type: "success", text: "晨报调度配置已保存" });
|
||||
if (canManageExecutiveBriefing) {
|
||||
await executiveBriefingQuery.refetch();
|
||||
}
|
||||
if (dailyBriefingQuery.isFetched) {
|
||||
await dailyBriefingQuery.refetch();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save executive briefing config:", err);
|
||||
const message = getApiErrorMessage(err, "保存晨报配置失败");
|
||||
setExecutiveBriefingConfigError(message);
|
||||
setExecutiveBriefingSaveMessage({ type: "error", text: message });
|
||||
} finally {
|
||||
setExecutiveBriefingConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detectConflicts = async (projectId: number = 1) => {
|
||||
setFeatureLoading("detect-conflicts");
|
||||
try {
|
||||
await aiStewardApi.detectConflicts(projectId);
|
||||
await loadConflicts();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to detect conflicts:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const detectTaskBlockers = async () => {
|
||||
setFeatureLoading("detect-blockers");
|
||||
try {
|
||||
await aiStewardApi.detectTaskBlockers();
|
||||
await loadBlockers();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to detect blockers:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const startHNMonitor = async () => {
|
||||
setFeatureLoading("hn-monitor");
|
||||
try {
|
||||
await aiStewardApi.startHnMonitor(30);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to start HN monitor:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const loadEngineConfigs = async () => {
|
||||
setFeatureLoading("engine-list");
|
||||
try {
|
||||
const data = await aiStewardApi.listLlmEngines();
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load engine configs:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEngineProviderPresets = async () => {
|
||||
try {
|
||||
const data = await aiStewardApi.getLlmProviderPresets();
|
||||
const providers = data?.providers;
|
||||
const nextPresets: EngineProviderPresets = {
|
||||
deepseek: {
|
||||
model: providers?.deepseek?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.model,
|
||||
base_url: providers?.deepseek?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.base_url,
|
||||
},
|
||||
glm: {
|
||||
model: providers?.glm?.model || data?.low || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
|
||||
base_url: providers?.glm?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
|
||||
},
|
||||
openai: {
|
||||
model: providers?.openai?.model || data?.high || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.model,
|
||||
base_url: providers?.openai?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.base_url,
|
||||
},
|
||||
ollama: {
|
||||
model: providers?.ollama?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.model,
|
||||
base_url: providers?.ollama?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.base_url,
|
||||
},
|
||||
};
|
||||
setEngineProviderPresets(nextPresets);
|
||||
setEngineForm((prev) => {
|
||||
if (engineEditingId) return prev;
|
||||
const preset = nextPresets[prev.provider];
|
||||
return {
|
||||
...prev,
|
||||
model: preset.model,
|
||||
base_url: preset.base_url,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load provider presets:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetEngineForm = () => {
|
||||
setEngineEditingId(null);
|
||||
setEngineForm({
|
||||
name: "",
|
||||
provider: "glm",
|
||||
api_key: "",
|
||||
model: engineProviderPresets.glm.model,
|
||||
base_url: engineProviderPresets.glm.base_url,
|
||||
});
|
||||
};
|
||||
|
||||
const saveEngineConfig = async () => {
|
||||
setFeatureLoading("engine-save");
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const isEdit = Boolean(engineEditingId);
|
||||
const body: LLMEngineUpsertRequest = {
|
||||
name: engineForm.name || `${engineForm.provider.toUpperCase()} 引擎`,
|
||||
provider: engineForm.provider,
|
||||
model: engineForm.model || undefined,
|
||||
base_url: engineForm.base_url || undefined,
|
||||
};
|
||||
if (engineForm.api_key.trim()) {
|
||||
body.api_key = engineForm.api_key.trim();
|
||||
}
|
||||
if (!isEdit) {
|
||||
body.set_active = true;
|
||||
}
|
||||
const data = isEdit
|
||||
? await aiStewardApi.updateLlmEngine(String(engineEditingId), body)
|
||||
: await aiStewardApi.createLlmEngine(body);
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
setEngineMessage({ type: "success", text: isEdit ? "引擎配置已更新" : "引擎配置已新增" });
|
||||
resetEngineForm();
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,保存失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activateEngine = async (engineId: string) => {
|
||||
setFeatureLoading(`engine-activate-${engineId}`);
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.activateLlmEngine(engineId);
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
setEngineMessage({ type: "success", text: "已切换激活引擎" });
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,切换失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEngine = async (engineId: string) => {
|
||||
if (!confirm("确定删除该引擎配置吗?")) return;
|
||||
setFeatureLoading(`engine-delete-${engineId}`);
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.deleteLlmEngine(engineId);
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
if (engineEditingId === engineId) {
|
||||
resetEngineForm();
|
||||
}
|
||||
setEngineMessage({ type: "success", text: "引擎配置已删除" });
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,删除失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const testEngineConnection = async (engineId?: string) => {
|
||||
const key = engineId ? `engine-test-${engineId}` : "engine-test-active";
|
||||
setFeatureLoading(key);
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = engineId ? await aiStewardApi.testLlmEngine(engineId) : await aiStewardApi.testActiveLlmEngine();
|
||||
const name = data?.name || "未知引擎";
|
||||
const latency = typeof data?.latency_ms === "number" ? `${data.latency_ms}ms` : "-";
|
||||
const preview = String(data?.response_preview || "").slice(0, 60);
|
||||
setEngineMessage({
|
||||
type: "success",
|
||||
text: `连通成功:${name}(延迟 ${latency})${preview ? `,响应:${preview}` : ""}`,
|
||||
});
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,连通测试失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLocalKbQaSetting = async (checked: boolean) => {
|
||||
setFeatureLoading("engine-settings");
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.updateLlmEngineSettings({ enable_local_kb_qa: checked });
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
setEngineMessage({
|
||||
type: "success",
|
||||
text: checked ? "已启用本地 LLM,仅知识库问答可使用" : "已禁用本地 LLM",
|
||||
});
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "更新开关失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditEngine = (engine: LLMEngine) => {
|
||||
setEngineEditingId(engine.id);
|
||||
setEngineForm({
|
||||
name: engine.name || "",
|
||||
provider: engine.provider,
|
||||
api_key: "",
|
||||
model: engine.model || "",
|
||||
base_url: engine.base_url || "",
|
||||
});
|
||||
setEngineMessage(null);
|
||||
};
|
||||
|
||||
const updateExecutiveBriefingDraft = (patch: Partial<ExecutiveBriefingConfig>) => {
|
||||
setExecutiveBriefingConfigDraft((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
}));
|
||||
};
|
||||
|
||||
// ==================== Project Creation ====================
|
||||
|
||||
const handleProjectSubmit = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) {
|
||||
setError("请输入项目描述");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setProjectResult(null);
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
projectCreationMutation.mutate(text);
|
||||
};
|
||||
|
||||
const syncUrlParams = (
|
||||
tab: AIStewardTab,
|
||||
section: FeedbackCenterSection,
|
||||
nextWindowDays: FeedbackCenterTrendWindowDays = trendWindowDays,
|
||||
nextSourceGroup: FeedbackCenterSourceGroup = trendSourceGroup
|
||||
) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("tab", tab);
|
||||
next.set("section", section);
|
||||
next.set("window", String(nextWindowDays));
|
||||
next.set("scope", nextSourceGroup);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const nextTab = normalizeTab(value);
|
||||
if (nextTab === "feedback-center" && !canViewFeedbackCenter) {
|
||||
setActiveTab("overview");
|
||||
syncUrlParams("overview", "overview");
|
||||
return;
|
||||
}
|
||||
setActiveTab(nextTab);
|
||||
syncUrlParams(nextTab, feedbackSection);
|
||||
};
|
||||
|
||||
const openBriefingTarget = (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => {
|
||||
if (!target?.type || !target.ref) {
|
||||
if (fallbackTab) {
|
||||
handleTabChange(fallbackTab);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const serializedParams = serializeTargetParams(target.params);
|
||||
if (target.type === "project_board") {
|
||||
navigate(`/projects/${target.ref}/board${serializedParams ? `?${serializedParams}` : ""}`);
|
||||
return;
|
||||
}
|
||||
if (target.type === "workload_detail") {
|
||||
navigate(`/workloads/${target.ref}${serializedParams ? `?${serializedParams}` : ""}`);
|
||||
return;
|
||||
}
|
||||
if (target.type === "user_profile") {
|
||||
navigate(`/users/${target.ref}/profile${serializedParams ? `?${serializedParams}` : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTab = normalizeTab(target.ref);
|
||||
setActiveTab(nextTab);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("tab", nextTab);
|
||||
next.set("section", String(target.params?.section || feedbackSection));
|
||||
next.set("window", String(target.params?.window || trendWindowDays));
|
||||
next.set("scope", String(target.params?.scope || trendSourceGroup));
|
||||
for (const [key, value] of Object.entries(target.params || {})) {
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
next.set(key, String(value));
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => {
|
||||
setFeedbackSection(nextSection);
|
||||
syncUrlParams(activeTab, nextSection);
|
||||
};
|
||||
|
||||
const handleTrendWindowDaysChange = (nextWindowDays: FeedbackCenterTrendWindowDays) => {
|
||||
setTrendWindowDays(nextWindowDays);
|
||||
syncUrlParams(activeTab, feedbackSection, nextWindowDays, trendSourceGroup);
|
||||
};
|
||||
|
||||
const handleTrendSourceGroupChange = (nextSourceGroup: FeedbackCenterSourceGroup) => {
|
||||
setTrendSourceGroup(nextSourceGroup);
|
||||
syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup);
|
||||
};
|
||||
|
||||
const buildExportPayload = (): PrintableBriefingPayload | null => {
|
||||
if (canManageExecutiveBriefing && executiveBriefing) {
|
||||
return {
|
||||
brief_date: executiveBriefing.brief_date,
|
||||
mode: executiveBriefing.mode,
|
||||
hero: executiveBriefing.hero,
|
||||
sections: executiveBriefing.sections,
|
||||
export_title: executiveBriefing.hero.title || "简报中心",
|
||||
viewer_scope: "tenant_full",
|
||||
};
|
||||
}
|
||||
if (dailyBriefing) {
|
||||
return dailyBriefing;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const exportCurrentReport = async () => {
|
||||
const payload = buildExportPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const { printBriefingReport } = await import("./components/BriefingPrintDocument");
|
||||
printBriefingReport(payload, user?.name || user?.username || undefined);
|
||||
};
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case "overview":
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<OverviewTab
|
||||
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
||||
dailyBriefing={dailyBriefing}
|
||||
dailyBriefingError={dailyBriefingError}
|
||||
dailyBriefingLoading={dailyBriefingLoading}
|
||||
username={user?.username}
|
||||
executiveBriefing={executiveBriefing}
|
||||
executiveBriefingError={executiveBriefingError}
|
||||
executiveBriefingMode={briefingMode}
|
||||
executiveBriefingLoading={executiveBriefingLoading}
|
||||
executiveBriefingConfigDraft={executiveBriefingConfigDraft}
|
||||
executiveBriefingConfigLoading={executiveBriefingConfigLoading}
|
||||
executiveBriefingConfigError={executiveBriefingConfigError}
|
||||
executiveBriefingSaveLoading={executiveBriefingConfigSaving}
|
||||
executiveBriefingSaveMessage={executiveBriefingSaveMessage}
|
||||
onLoadSnapshot={() => switchBriefingMode("snapshot")}
|
||||
onRefreshLive={() => switchBriefingMode("live")}
|
||||
onExportReport={() => void exportCurrentReport()}
|
||||
onConfigChange={updateExecutiveBriefingDraft}
|
||||
onSaveConfig={() => void saveExecutiveBriefingConfig()}
|
||||
onOpenTarget={openBriefingTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
case "create":
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<CreateProjectTab
|
||||
input={input}
|
||||
loading={projectCreationMutation.isPending}
|
||||
error={error}
|
||||
projectResult={projectResult}
|
||||
onInputChange={setInput}
|
||||
onSubmit={handleProjectSubmit}
|
||||
onResetProject={() => {
|
||||
setProjectResult(null);
|
||||
setError(null);
|
||||
projectCreationMutation.reset();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
case "signals":
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<SignalsTab
|
||||
featureLoading={featureLoading}
|
||||
onStartHNMonitor={() => void startHNMonitor()}
|
||||
onOpenFeishuConfig={() => navigate("/feishu-config")}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
case "conflicts":
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<ConflictsTab
|
||||
featureLoading={featureLoading}
|
||||
conflicts={conflicts}
|
||||
onDetectConflicts={() => void detectConflicts()}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
case "blockers":
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<BlockersTab
|
||||
featureLoading={featureLoading}
|
||||
blockers={blockers}
|
||||
onDetectTaskBlockers={() => void detectTaskBlockers()}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
case "qa":
|
||||
return (
|
||||
<TabsContent value="qa" className="mt-6">
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<QAHistoryPanel />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
);
|
||||
case "feedback-center":
|
||||
if (!canViewFeedbackCenter) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<FeedbackCenterTab
|
||||
section={feedbackSection}
|
||||
projectId={Number(searchParams.get("project_id") || "0") || null}
|
||||
onSectionChange={handleFeedbackSectionChange}
|
||||
trendWindowDays={trendWindowDays}
|
||||
trendSourceGroup={trendSourceGroup}
|
||||
onTrendWindowDaysChange={handleTrendWindowDaysChange}
|
||||
onTrendSourceGroupChange={handleTrendSourceGroupChange}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
case "engine":
|
||||
return (
|
||||
<Suspense fallback={<SectionLoadingShell blocks={2} compact />}>
|
||||
<EngineTab
|
||||
featureLoading={featureLoading}
|
||||
engineData={engineData}
|
||||
engineMessage={engineMessage}
|
||||
engineForm={engineForm}
|
||||
engineEditingId={engineEditingId}
|
||||
engineProviderPresets={engineProviderPresets}
|
||||
setEngineForm={setEngineForm}
|
||||
onTestActiveEngine={() => void testEngineConnection()}
|
||||
onRefreshEngines={() => void loadEngineConfigs()}
|
||||
onUpdateLocalKbQaSetting={(checked) => void updateLocalKbQaSetting(checked)}
|
||||
onActivateEngine={(engineId) => void activateEngine(engineId)}
|
||||
onTestEngine={(engineId) => void testEngineConnection(engineId)}
|
||||
onStartEditEngine={startEditEngine}
|
||||
onDeleteEngine={(engineId) => void deleteEngine(engineId)}
|
||||
onSaveEngineConfig={() => void saveEngineConfig()}
|
||||
onResetEngineForm={resetEngineForm}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Initial Load ====================
|
||||
|
||||
useEffect(() => {
|
||||
const urlTab = normalizeTab(searchParams.get("tab"));
|
||||
const urlSection = normalizeSection(searchParams.get("section"));
|
||||
const safeTab = urlTab === "feedback-center" && !canViewFeedbackCenter ? "overview" : urlTab;
|
||||
if (safeTab !== activeTab) {
|
||||
setActiveTab(safeTab);
|
||||
}
|
||||
if (urlSection !== feedbackSection) {
|
||||
setFeedbackSection(urlSection);
|
||||
}
|
||||
const urlWindow = normalizeTrendWindow(searchParams.get("window"));
|
||||
if (urlWindow !== trendWindowDays) {
|
||||
setTrendWindowDays(urlWindow);
|
||||
}
|
||||
const urlSourceGroup = normalizeTrendSourceGroup(searchParams.get("scope"));
|
||||
if (urlSourceGroup !== trendSourceGroup) {
|
||||
setTrendSourceGroup(urlSourceGroup);
|
||||
}
|
||||
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "overview" && canManageExecutiveBriefing) {
|
||||
void loadExecutiveBriefingConfig();
|
||||
}
|
||||
if (activeTab === "conflicts") loadConflicts();
|
||||
if (activeTab === "blockers") loadBlockers();
|
||||
if (activeTab === "engine") {
|
||||
loadEngineProviderPresets();
|
||||
loadEngineConfigs();
|
||||
}
|
||||
}, [activeTab, canManageExecutiveBriefing]);
|
||||
|
||||
// ==================== Render ====================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-4 flex items-center justify-center gap-3">
|
||||
<Sparkles className={`h-10 w-10 ${canManageExecutiveBriefing ? "text-amber-500" : "text-violet-500"}`} />
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
||||
简报中心
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||
{canManageExecutiveBriefing
|
||||
? "晨报快照、实时刷新与管理群推送共用同一套租户级简报内核"
|
||||
: "与你相关的任务、风险、负载和对齐事项都集中在一个入口里"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<SectionLoadingShell className="mb-8 mt-0" blocks={4} compact />}>
|
||||
<FeatureOverviewCards
|
||||
onSelectTab={(tab) => handleTabChange(tab)}
|
||||
canManageExecutiveBriefing={canManageExecutiveBriefing}
|
||||
executiveBriefing={executiveBriefing}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Main Content */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">
|
||||
简报中心
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
||||
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
||||
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
||||
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
|
||||
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
|
||||
{canViewFeedbackCenter && (
|
||||
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{renderActiveTab()}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const location = useLocation();
|
||||
return <Navigate to={getLegacyAIStewardRedirectPath(location.search)} replace />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,326 +1,26 @@
|
|||
import { AlertCircle, AlertTriangle, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||
import {
|
||||
formatProjectNameWithId,
|
||||
formatTechnicalIdentifierLabel,
|
||||
toZhPriority,
|
||||
toZhSeverity,
|
||||
toZhSourceKind,
|
||||
} from "@/lib/display";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EXAMPLE_INPUTS } from "../constants";
|
||||
import type { ProjectFromTextResponse } from "../types";
|
||||
|
||||
function formatCharterContent(
|
||||
content: ProjectFromTextResponse["charter"]["content"] | string | null | undefined
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim() || "-";
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const record = content as Record<string, unknown>;
|
||||
const text = typeof record.rationale === "string" ? record.rationale.trim() : "";
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content, null, 2);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function groupSignalSources(projectResult: ProjectFromTextResponse | null) {
|
||||
const sources = projectResult?.signal_sources ?? [];
|
||||
const groups = new Map<string, typeof sources>();
|
||||
for (const source of sources) {
|
||||
const section = String(source.section || "其他").trim() || "其他";
|
||||
const bucket = groups.get(section) || [];
|
||||
bucket.push(source);
|
||||
groups.set(section, bucket);
|
||||
}
|
||||
const preferredOrder = ["核心痛点", "MVP 功能", "其他"];
|
||||
return [
|
||||
...preferredOrder.filter((section) => groups.has(section)),
|
||||
...Array.from(groups.keys()).filter((section) => !preferredOrder.includes(section)),
|
||||
].map((section) => ({
|
||||
section,
|
||||
items: groups.get(section) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
interface CreateProjectTabProps {
|
||||
input: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
projectResult: ProjectFromTextResponse | null;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onResetProject: () => void;
|
||||
}
|
||||
|
||||
export default function CreateProjectTab({
|
||||
input,
|
||||
loading,
|
||||
error,
|
||||
projectResult,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onResetProject,
|
||||
}: CreateProjectTabProps) {
|
||||
const techStack = Array.isArray(projectResult?.analysis.tech_stack) ? projectResult.analysis.tech_stack : [];
|
||||
const tasks = Array.isArray(projectResult?.tasks) ? projectResult.tasks : [];
|
||||
const risks = Array.isArray(projectResult?.risks) ? projectResult.risks : [];
|
||||
const requirementItems = Array.isArray(projectResult?.analysis.requirement_items)
|
||||
? projectResult.analysis.requirement_items
|
||||
: [];
|
||||
const signalSourceGroups = groupSignalSources(projectResult);
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
export default function CreateProjectTab() {
|
||||
return (
|
||||
<TabsContent value="create" className="mt-6">
|
||||
{!projectResult ? (
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">描述你的项目</label>
|
||||
<Textarea
|
||||
placeholder="例如:我们要做一个智能客服工具,基于大语言模型,对标同类竞品,3 周上线"
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">示例输入(点击填充)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXAMPLE_INPUTS.map((example, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onInputChange(example)}
|
||||
className="px-3 py-1.5 text-sm bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
{example.length > 30 ? `${example.substring(0, 30)}...` : example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={onSubmit} disabled={loading || !input.trim()} className="w-full bg-violet-600 hover:bg-violet-700" size="lg">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
AI 正在生成项目…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
一键生成项目
|
||||
</>
|
||||
)}
|
||||
<Card className="border-blue-100 bg-blue-50/70">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-950">旧项目创建入口已下线</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
新建项目统一进入新项目启动台,由 AI 先生成可审查草案,确认后再创建真实项目。
|
||||
</p>
|
||||
<Button asChild className="mt-5 bg-[#2463EB] hover:bg-[#1f58d5]">
|
||||
<Link to="/project-launch">
|
||||
前往新项目启动台
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900 dark:text-green-100">项目创建成功!</h3>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">AI 已为您生成了完整的项目配置,包含信号分析、信号源、宪章、任务链和风险预警</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="project-overview">
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="project-overview" className="shrink-0 whitespace-nowrap">项目概览</TabsTrigger>
|
||||
<TabsTrigger value="project-charter" className="shrink-0 whitespace-nowrap">项目宪章</TabsTrigger>
|
||||
<TabsTrigger value="project-tasks" className="shrink-0 whitespace-nowrap">任务列表</TabsTrigger>
|
||||
<TabsTrigger value="project-risks" className="shrink-0 whitespace-nowrap">风险预警</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="project-overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{formatProjectNameWithId({
|
||||
id: projectResult.project.id,
|
||||
title: projectResult.project.title || projectResult.project.name,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>{projectResult.project.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-slate-500">技术栈</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{techStack.map((tech, i) => (
|
||||
<Badge key={i} variant="secondary">{tech}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-500">时间周期</label>
|
||||
<p className="mt-1">{projectResult.analysis.timeline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onResetProject} variant="outline" className="w-full">
|
||||
创建另一个项目
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="project-charter">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>项目宪章</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-semibold mb-2">{projectResult.charter.title}</h3>
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-slate-50 p-4 text-sm leading-6 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{formatCharterContent(projectResult.charter.content)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="project-tasks">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 生成的任务链</CardTitle>
|
||||
<CardDescription>
|
||||
共 {tasks.length} 个任务
|
||||
{requirementItems.length > 0 ? ` · 抽取 ${requirementItems.length} 个需求点` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task, index) => (
|
||||
<div key={task.id} className="flex items-start gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-violet-100 dark:bg-violet-900 flex items-center justify-center text-violet-600 dark:text-violet-400 text-xs font-semibold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold">{task.title}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline">{task.type}</Badge>
|
||||
<Badge variant={String(task.priority || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||
{toZhPriority(task.priority)}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-500">{task.estimated_hours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{signalSourceGroups.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>信号源</CardTitle>
|
||||
<CardDescription>项目创建时,AI 已把需求点直接落成了现有信号源注册项。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{signalSourceGroups.map(({ section, items }) => (
|
||||
<div key={section} className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{section}</h4>
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((source) => {
|
||||
const sourceKey = String(source.registry_id ?? source.id ?? source.source_identifier ?? source.source_name);
|
||||
return (
|
||||
<div key={sourceKey} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-700 dark:bg-slate-900/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h5 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{source.source_name}
|
||||
</h5>
|
||||
<Badge variant="secondary">
|
||||
{source.display_source_kind || toZhSourceKind(source.source_kind)}
|
||||
</Badge>
|
||||
{source.origin && <Badge variant="outline">{source.origin}</Badge>}
|
||||
</div>
|
||||
{source.title && source.title !== source.source_name && (
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{source.title}</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||
{source.description || "暂无描述"}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{source.requested_via && <span>入口:{source.requested_via}</span>}
|
||||
{source.registry_id != null && <span>#{source.registry_id}</span>}
|
||||
{(source.technical_identifier || source.source_identifier) && (
|
||||
<span className="font-mono">
|
||||
{formatTechnicalIdentifierLabel(source.technical_identifier || source.source_identifier)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={String(source.priority || "").toLowerCase() === "high" ? "destructive" : "outline"}>
|
||||
{toZhPriority(source.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<TabsContent value="project-risks">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 风险预警规则</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{risks.map((risk, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<AlertTriangle className={`w-5 h-5 ${String(risk.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
|
||||
<div>
|
||||
<h4 className="font-semibold">{risk.description}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline">{risk.type}</Badge>
|
||||
<Badge variant={String(risk.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||
{toZhSeverity(risk.severity)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ export type {
|
|||
ExecutiveBriefingMetric,
|
||||
ExecutiveBriefingPayload,
|
||||
ExecutiveBriefingSection,
|
||||
ProjectFromTextRequest,
|
||||
ProjectFromTextRequirementItem,
|
||||
ProjectFromTextResponse,
|
||||
ProjectFromTextSignalSource,
|
||||
} from "@/api/aiSteward";
|
||||
export type { FeedbackCenterTrendWindowDays, FeedbackCenterSourceGroup } from "@/api/feedbackCenter";
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ export default function DashboardPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">团队负载看板</h1>
|
||||
<p className="text-muted-foreground">查看团队成员工作负载分布</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">容量明细看板</h1>
|
||||
<p className="text-muted-foreground">查看团队成员容量信号分布,仅作为执行能力图谱的参考信号</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
|
|
@ -80,7 +80,7 @@ export default function DashboardPage() {
|
|||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>负载明细</CardTitle>
|
||||
<CardTitle>容量明细</CardTitle>
|
||||
<CardDescription>按成员统计任务数与工时</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -1,500 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { mountedAiApi, type MountedAIRunItem } from "@/api/mountedAi";
|
||||
import { notificationsApi } from "@/api/notifications";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import InboxActionPanel, { type InboxProjectOption } from "@/components/inbox/InboxActionPanel";
|
||||
import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { buildInboxView } from "@/lib/inbox";
|
||||
import type { InboxActionDescriptor, InboxItemViewModel } from "@/types/inbox";
|
||||
import InboxFiltersPanel from "./components/InboxFiltersPanel";
|
||||
import {
|
||||
POLL_INTERVAL_MS,
|
||||
matchesTab,
|
||||
normalizeTabKey,
|
||||
parsePositiveInt,
|
||||
sortByLatest,
|
||||
isMissingProjectScopeItem,
|
||||
type InboxTabKey,
|
||||
} from "./helpers";
|
||||
|
||||
function remapDraftInboxItem(item: InboxItemViewModel): InboxItemViewModel {
|
||||
if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return item;
|
||||
const state = String(item.source_work_item.state || "").toLowerCase();
|
||||
if (state !== "draft_pending_confirm") return item;
|
||||
|
||||
const draftPayload = item.source_work_item.draft_content && typeof item.source_work_item.draft_content === "object"
|
||||
? item.source_work_item.draft_content as Record<string, unknown>
|
||||
: null;
|
||||
const draftTitle = String(draftPayload?.title || "").trim();
|
||||
const draftSummary = String(draftPayload?.summary || "").trim();
|
||||
const draftExpiresAt = String(item.source_work_item.draft_expires_at || "").trim();
|
||||
return {
|
||||
...item,
|
||||
display_kind: "action_required",
|
||||
title: draftTitle || item.title,
|
||||
summary: draftSummary || item.summary,
|
||||
stage_text: "草稿待确认",
|
||||
next_step_text: `回复确认入库、修改或取消${draftExpiresAt ? `(截止 ${draftExpiresAt})` : ""}`,
|
||||
primary_action: { kind: "confirm", label: "确认入库" } satisfies InboxActionDescriptor,
|
||||
secondary_action: { kind: "revise_draft", label: "修改" } satisfies InboxActionDescriptor,
|
||||
weak_action: { kind: "cancel_draft", label: "取消" } satisfies InboxActionDescriptor,
|
||||
priority_rank: Math.max(3, Number(item.priority_rank || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function mapMountedRunInboxItem(item: MountedAIRunItem): InboxItemViewModel {
|
||||
const mode = String(item.mode_after || item.mode_before || "").toLowerCase();
|
||||
const agent = String(item.active_agent || "").toLowerCase();
|
||||
const status = String(item.status || "").toLowerCase();
|
||||
|
||||
let displayKind: InboxItemViewModel["display_kind"] = "info";
|
||||
let stageText = "挂载运行记录";
|
||||
let nextStepText = "打开调度台查看完整 trace";
|
||||
let primaryAction: InboxActionDescriptor = { kind: "open_target", label: "查看调度台" };
|
||||
|
||||
if (mode === "clarify" || agent.includes("clarifier")) {
|
||||
displayKind = "action_required";
|
||||
stageText = "等待澄清";
|
||||
nextStepText = "请补充上下文后继续";
|
||||
primaryAction = { kind: "open_target", label: "继续澄清" };
|
||||
} else if (mode === "draft_ready") {
|
||||
displayKind = "action_required";
|
||||
stageText = "草稿待确认";
|
||||
nextStepText = "已达到出草稿条件,等待确认";
|
||||
primaryAction = { kind: "open_target", label: "查看草稿准备态" };
|
||||
} else if (agent.includes("assignee") || agent.includes("recommender")) {
|
||||
displayKind = "action_required";
|
||||
stageText = "负责人建议";
|
||||
nextStepText = "查看建议并决定是否采纳";
|
||||
primaryAction = { kind: "open_target", label: "查看负责人建议" };
|
||||
} else if (mode === "execute" && (status === "queued" || status === "running")) {
|
||||
displayKind = "auto_running";
|
||||
stageText = "执行中";
|
||||
nextStepText = "执行器正在处理写操作";
|
||||
} else if (mode === "execute" && status === "completed") {
|
||||
displayKind = "auto_done";
|
||||
stageText = "执行完成";
|
||||
nextStepText = "执行结果已写入,查看 run replay";
|
||||
} else if (status === "queued" || status === "running") {
|
||||
displayKind = "auto_running";
|
||||
stageText = "AI处理中";
|
||||
nextStepText = "打开调度台查看当前 agent 编排";
|
||||
}
|
||||
|
||||
return {
|
||||
id: `mounted-run-${item.id}`,
|
||||
source_id: String(item.id),
|
||||
source_kind: "mounted_ai_run",
|
||||
display_kind: displayKind,
|
||||
title: item.trigger_reason ? `Mounted Run · ${item.trigger_reason}` : `Mounted Run #${item.id}`,
|
||||
summary: item.final_reply || "该 run 暂无最终回复,请打开调度台查看 trace。",
|
||||
primary_action: primaryAction,
|
||||
secondary_action: { kind: "open_detail", label: "展开详情" },
|
||||
weak_action: { kind: "none", label: "收起" },
|
||||
priority: displayKind === "action_required" ? "high" : "info",
|
||||
priority_rank: displayKind === "action_required" ? 3 : displayKind === "auto_running" ? 2 : 1,
|
||||
is_read: false,
|
||||
dedupe_count: 1,
|
||||
can_undo: false,
|
||||
target_url: `/scheduler-center?mountedRunId=${item.id}`,
|
||||
created_at: item.updated_at,
|
||||
updated_at: item.updated_at,
|
||||
stage_text: stageText,
|
||||
next_step_text: nextStepText,
|
||||
source_mounted_run: item,
|
||||
};
|
||||
}
|
||||
|
||||
export default function InboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
const [selectedProjectByItem, setSelectedProjectByItem] = useState<Record<string, string>>({});
|
||||
const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false);
|
||||
|
||||
const activeTab = normalizeTabKey(searchParams.get("section"));
|
||||
const projectFilter = searchParams.get("project_id") || "all";
|
||||
const searchValue = searchParams.get("q") || "";
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["notifications", "inbox"],
|
||||
queryFn: () => notificationsApi.list({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const workItemsQuery = useQuery({
|
||||
queryKey: ["ai-pmo-work-items", "inbox"],
|
||||
queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const activeProjectsQuery = useQuery({
|
||||
queryKey: ["projects", "active", "inbox-inline-actions"],
|
||||
queryFn: () => projectsApi.list({ status: "active" }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
const mountedRunsQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "runs", "inbox"],
|
||||
queryFn: () => mountedAiApi.getRuns({ limit: 40 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const inboxData = useMemo(() => {
|
||||
return buildInboxView({
|
||||
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
|
||||
workItems: workItemsQuery.data || { items: [] },
|
||||
});
|
||||
}, [notificationsQuery.data, workItemsQuery.data]);
|
||||
|
||||
const viewItems = useMemo(() => {
|
||||
const mountedItems = (mountedRunsQuery.data?.items || []).map((item) => mapMountedRunInboxItem(item));
|
||||
return [...inboxData.items.map((item) => remapDraftInboxItem(item)), ...mountedItems];
|
||||
}, [inboxData.items, mountedRunsQuery.data?.items]);
|
||||
|
||||
const projectOptions = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of viewItems) {
|
||||
if (!item.project_id) continue;
|
||||
map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`);
|
||||
}
|
||||
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
|
||||
}, [viewItems]);
|
||||
|
||||
const activeProjectOptions = useMemo<InboxProjectOption[]>(() => {
|
||||
const projects = activeProjectsQuery.data || [];
|
||||
return projects.map((project) => ({
|
||||
value: String(project.id),
|
||||
label: String(project.title || `项目 #${project.id}`),
|
||||
}));
|
||||
}, [activeProjectsQuery.data]);
|
||||
|
||||
const projectScopedItems = useMemo(() => {
|
||||
return viewItems.filter((item) => {
|
||||
if (projectFilter === "all") return true;
|
||||
return String(item.project_id || "") === projectFilter;
|
||||
});
|
||||
}, [viewItems, projectFilter]);
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
all: projectScopedItems.length,
|
||||
needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
|
||||
in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
|
||||
done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length,
|
||||
} satisfies Record<InboxTabKey, number>;
|
||||
}, [projectScopedItems]);
|
||||
|
||||
const searchedItems = useMemo(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
if (!query) return projectScopedItems;
|
||||
|
||||
return projectScopedItems.filter((item) => {
|
||||
return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}`
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
}, [projectScopedItems, searchValue]);
|
||||
|
||||
const feedItems = useMemo(() => {
|
||||
const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab));
|
||||
return sortByLatest(tabItems);
|
||||
}, [searchedItems, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpandedByQuery) return;
|
||||
const targetId = parsePositiveInt(searchParams.get("workItemId"));
|
||||
if (!targetId) return;
|
||||
const targetKey = `work-item-${targetId}`;
|
||||
const matched = feedItems.find((item) => item.id === targetKey);
|
||||
if (matched) {
|
||||
setExpandedItemId(targetKey);
|
||||
setAutoExpandedByQuery(true);
|
||||
}
|
||||
}, [autoExpandedByQuery, feedItems, searchParams]);
|
||||
|
||||
const setTab = (tab: InboxTabKey) => {
|
||||
const normalized = normalizeTabKey(tab);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", normalized);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const setProject = (projectId: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (projectId === "all") next.delete("project_id");
|
||||
else next.set("project_id", projectId);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (!value.trim()) next.delete("q");
|
||||
else next.set("q", value.trim());
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: string) => {
|
||||
setExpandedItemId((current) => (current === itemId ? null : itemId));
|
||||
};
|
||||
|
||||
const getSelectedProjectId = (item: InboxItemViewModel): number | null => {
|
||||
const selected = selectedProjectByItem[item.id];
|
||||
if (selected) return parsePositiveInt(selected);
|
||||
if (item.project_id) return parsePositiveInt(item.project_id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSelectedProjectValue = (item: InboxItemViewModel): string => {
|
||||
const selected = selectedProjectByItem[item.id];
|
||||
if (selected) return selected;
|
||||
return item.project_id ? String(item.project_id) : "";
|
||||
};
|
||||
|
||||
const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => {
|
||||
setSelectedProjectByItem((current) => ({ ...current, [item.id]: value }));
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-pmo-work-items", "inbox"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["mounted-ai", "runs", "inbox"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const markNotificationRead = async (item: InboxItemViewModel) => {
|
||||
if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) {
|
||||
try {
|
||||
await notificationsApi.markRead(Number(item.source_notification.id));
|
||||
} catch {
|
||||
// 不阻断后续操作。
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openTarget = async (item: InboxItemViewModel) => {
|
||||
await markNotificationRead(item);
|
||||
|
||||
if (item.target_url) {
|
||||
if (item.target_url.startsWith("/")) navigate(item.target_url);
|
||||
else window.location.assign(item.target_url);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "当前消息暂无可打开的目标入口", variant: "default" });
|
||||
};
|
||||
|
||||
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
|
||||
const actionKey = buildInboxActionKey(item, action);
|
||||
setPendingActionKey(actionKey);
|
||||
|
||||
try {
|
||||
if (action.kind === "open_target") {
|
||||
await openTarget(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "open_detail") {
|
||||
await markNotificationRead(item);
|
||||
toggleExpand(item.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const workItemState = String(item.source_work_item.state || "").toLowerCase();
|
||||
if (workItemState === "draft_pending_confirm") {
|
||||
const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, {
|
||||
action: "confirm",
|
||||
draft_version: item.source_work_item.draft_version ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
if (result.status === "succeeded") {
|
||||
toast({ title: followupReply || "草稿已确认并入库", variant: "success" });
|
||||
} else if (result.status === "pending_manual") {
|
||||
toast({ title: followupReply || "草稿确认后仍需人工处理", variant: "default" });
|
||||
} else {
|
||||
toast({ title: followupReply || "草稿确认失败,请查看详情", variant: "error" });
|
||||
}
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const missingProjectScope = isMissingProjectScopeItem(item);
|
||||
const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null);
|
||||
if (missingProjectScope && !selectedProjectId) {
|
||||
setExpandedItemId(item.id);
|
||||
toast({ title: "请先选择关联项目后再确认", variant: "default" });
|
||||
return;
|
||||
}
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "approve",
|
||||
project_id: selectedProjectId ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
if (result.status === "succeeded") {
|
||||
toast({ title: followupReply || "已继续执行并完成", variant: "success" });
|
||||
} else if (result.status === "pending_manual") {
|
||||
toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" });
|
||||
} else {
|
||||
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
|
||||
}
|
||||
setSelectedProjectByItem((current) => {
|
||||
if (!(item.id in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "revise_draft" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const revision = window.prompt("请输入修改意见", "");
|
||||
if (revision === null) return;
|
||||
const trimmed = revision.trim();
|
||||
if (!trimmed) {
|
||||
toast({ title: "请先输入修改意见", variant: "default" });
|
||||
return;
|
||||
}
|
||||
const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, {
|
||||
action: "revise",
|
||||
revision_note: trimmed,
|
||||
draft_version: item.source_work_item.draft_version ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
toast({ title: followupReply || "草稿已重新起草", variant: "success" });
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "cancel_draft" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, {
|
||||
action: "cancel",
|
||||
draft_version: item.source_work_item.draft_version ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
toast({ title: followupReply || "草稿已取消", variant: "default" });
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "reject",
|
||||
reason: "人工审批拒绝,已标记失效",
|
||||
});
|
||||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
|
||||
await notificationsApi.markRead(item.source_notification.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "none") {
|
||||
toggleExpand(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error), variant: "error" });
|
||||
} finally {
|
||||
setPendingActionKey((current) => (current === actionKey ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await notificationsApi.markAllRead();
|
||||
await refreshAll();
|
||||
toast({ title: "站内通知已全部设为已读", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading || mountedRunsQuery.isLoading;
|
||||
const activeProjectErrorText = activeProjectsQuery.isError
|
||||
? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-12">
|
||||
<InboxFiltersPanel
|
||||
feedCount={feedItems.length}
|
||||
activeTab={activeTab}
|
||||
projectFilter={projectFilter}
|
||||
searchValue={searchValue}
|
||||
projectOptions={projectOptions}
|
||||
tabCounts={tabCounts}
|
||||
disableMarkAllRead={notificationsQuery.data?.unread_count === 0}
|
||||
onMarkAllRead={() => void markAllRead()}
|
||||
onSetSearch={setSearch}
|
||||
onSetProject={setProject}
|
||||
onSetTab={setTab}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
加载消息台中…
|
||||
</div>
|
||||
</section>
|
||||
) : feedItems.length === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
|
||||
<BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
|
||||
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
|
||||
{feedItems.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedItemId === item.id}
|
||||
pendingActionKey={pendingActionKey}
|
||||
inlinePanel={
|
||||
expandedItemId === item.id && isMissingProjectScopeItem(item) ? (
|
||||
<InboxActionPanel
|
||||
selectedProjectId={getSelectedProjectValue(item)}
|
||||
projectOptions={activeProjectOptions}
|
||||
loadingProjects={activeProjectsQuery.isLoading}
|
||||
projectsErrorText={activeProjectErrorText}
|
||||
pending={pendingActionKey === buildInboxActionKey(item, item.primary_action)}
|
||||
onSelectProject={(value) => setSelectedProjectValue(item, value)}
|
||||
onCancel={() => setExpandedItemId(null)}
|
||||
onConfirm={() => void handleAction(item, item.primary_action)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onToggleExpand={toggleExpand}
|
||||
onAction={(target, action) => void handleAction(target, action)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import type { InboxTabKey } from "../helpers";
|
||||
import { TAB_LABELS } from "../helpers";
|
||||
import type { InboxProjectOption } from "@/components/inbox/InboxActionPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InboxFiltersPanelProps {
|
||||
feedCount: number;
|
||||
activeTab: InboxTabKey;
|
||||
projectFilter: string;
|
||||
searchValue: string;
|
||||
projectOptions: InboxProjectOption[];
|
||||
tabCounts: Record<InboxTabKey, number>;
|
||||
disableMarkAllRead: boolean;
|
||||
onMarkAllRead: () => void;
|
||||
onSetSearch: (value: string) => void;
|
||||
onSetProject: (projectId: string) => void;
|
||||
onSetTab: (tab: InboxTabKey) => void;
|
||||
}
|
||||
|
||||
export default function InboxFiltersPanel({
|
||||
feedCount,
|
||||
activeTab,
|
||||
projectFilter,
|
||||
searchValue,
|
||||
projectOptions,
|
||||
tabCounts,
|
||||
disableMarkAllRead,
|
||||
onMarkAllRead,
|
||||
onSetSearch,
|
||||
onSetProject,
|
||||
onSetTab,
|
||||
}: InboxFiltersPanelProps) {
|
||||
return (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
|
||||
<p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
|
||||
<p className="mt-1 text-xs text-slate-500">当前结果:{feedCount} 条</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={disableMarkAllRead}
|
||||
>
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Input
|
||||
placeholder="搜索标题、副标题、项目"
|
||||
defaultValue={searchValue}
|
||||
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
|
||||
onBlur={(event) => onSetSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onSetSearch((event.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(event) => onSetProject(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projectOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
type="button"
|
||||
onClick={() => onSetTab(tabKey)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
|
||||
activeTab === tabKey
|
||||
? "border-slate-300 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<span>{TAB_LABELS[tabKey]}</span>
|
||||
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { parseBackendDate } from "@/lib/datetime";
|
||||
import type { InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
export const POLL_INTERVAL_MS = 15000;
|
||||
|
||||
export type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
|
||||
|
||||
export const TAB_LABELS: Record<InboxTabKey, string> = {
|
||||
all: "全部",
|
||||
needs_action: "待处理",
|
||||
in_progress: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
export const TAB_TO_DISPLAY_KIND: Partial<Record<InboxTabKey, InboxDisplayKind>> = {
|
||||
needs_action: "action_required",
|
||||
in_progress: "auto_running",
|
||||
done: "auto_done",
|
||||
};
|
||||
|
||||
export function normalizeTabKey(raw?: string | null): InboxTabKey {
|
||||
const value = String(raw || "").trim();
|
||||
if (value === "action_required" || value === "needs_action") return "needs_action";
|
||||
if (value === "auto_running" || value === "in_progress") return "in_progress";
|
||||
if (value === "auto_done" || value === "done") return "done";
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] {
|
||||
const toTimestamp = (item: InboxItemViewModel): number => {
|
||||
const date = parseBackendDate(item.updated_at || item.created_at);
|
||||
return date ? date.getTime() : -1;
|
||||
};
|
||||
|
||||
return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a));
|
||||
}
|
||||
|
||||
export function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
|
||||
if (tab === "all") return true;
|
||||
return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
|
||||
}
|
||||
|
||||
export function parsePositiveInt(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
const integer = Math.trunc(parsed);
|
||||
return integer > 0 ? integer : null;
|
||||
}
|
||||
|
||||
export function isMissingProjectScopeItem(item: InboxItemViewModel): boolean {
|
||||
if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false;
|
||||
const state = String(item.source_work_item.state || "").toLowerCase();
|
||||
const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase();
|
||||
return state === "waiting_manual" && gateCode === "missing_project_scope";
|
||||
}
|
||||
|
|
@ -83,17 +83,17 @@ export default function ModelRoutingConfigPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="模型路由配置"
|
||||
description="按函数选择逻辑层级,只暴露中文说明,真实模型仍由配置真源解析。"
|
||||
title="高级路由详情"
|
||||
description="高级排障时按函数选择快速/深度槽位;团队日常策略请优先使用 AI 路由与供应商控制台。"
|
||||
icon={<Settings2 className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="model-routing-config"
|
||||
title="模型路由配置"
|
||||
purpose="给不同函数选择高低两层路由槽位,统一控制成本、速度和效果。"
|
||||
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的逻辑层。"
|
||||
title="高级路由详情"
|
||||
purpose="给高级排障保留函数级快速/深度槽位覆盖能力。"
|
||||
whenToUse="只有当控制台诊断已定位到具体函数,并需要临时切换槽位时使用。"
|
||||
steps={[
|
||||
"在右侧选中目标函数,左侧会自动回填当前逻辑 tier。",
|
||||
"只调整高层或低层,点“保存路由”。",
|
||||
|
|
|
|||
|
|
@ -572,7 +572,7 @@ export default function ProjectBoardPage() {
|
|||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">项目概览</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
汇总项目状态、团队负载、PMO 风险节奏与待处理工作项。
|
||||
汇总项目状态、容量信号、PMO 风险节奏与待处理工作项。
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-slate-600">
|
||||
|
|
@ -620,8 +620,8 @@ export default function ProjectBoardPage() {
|
|||
最新 cadence 快照已接入项目看板,风险、阻塞和待人工确认不再只存在旧队列里。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/inbox?project_id=${id}§ion=action_required`)}>
|
||||
查看收件箱
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/space?project_id=${id}`)}>
|
||||
查看个人空间
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
|
|
@ -658,7 +658,7 @@ export default function ProjectBoardPage() {
|
|||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-slate-900">AI PMO 工作项</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/space?project_id=${id}`)}>
|
||||
全部查看
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -666,14 +666,14 @@ export default function ProjectBoardPage() {
|
|||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
|
||||
<div className="text-sm text-slate-600">当前项目暂无 PMO 工作项。</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
|
||||
建议下一步:先在任务阶段创建首批任务,或前往个人空间确认待处理信号。
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setActiveStage("task")}>
|
||||
去任务阶段
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||||
打开消息台
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/space?project_id=${id}`)}>
|
||||
打开个人空间
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -683,7 +683,7 @@ export default function ProjectBoardPage() {
|
|||
key={item.id}
|
||||
type="button"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition hover:bg-white"
|
||||
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
|
||||
onClick={() => navigate(`/space?project_id=${id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="line-clamp-2 text-sm font-medium text-slate-900">{toReadableWorkItemTitle(item)}</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { useAuthStore } from "@/stores/auth";
|
|||
import ProjectQueryDialog from "./components/ProjectQueryDialog";
|
||||
import DeleteProjectDialog from "./components/DeleteProjectDialog";
|
||||
import {
|
||||
CHAIN_STAGES,
|
||||
LIVE_REFRESH_MS,
|
||||
canDeleteProjectByRole,
|
||||
normalizeProjects,
|
||||
|
|
@ -140,8 +139,8 @@ export default function ProjectsPage() {
|
|||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">项目列表</h1>
|
||||
<p className="mt-1 text-slate-500">管理所有项目 · SSTC 唯一事实链路</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">项目索引</h1>
|
||||
<p className="mt-1 text-slate-500">选择项目后进入 PMFlow OS 主链,按对象、执行、发布分流推进。</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
当前结果:{totalFiltered} 个项目
|
||||
{hasActiveFilters ? `(${appliedFilterSummary})` : ""}
|
||||
|
|
@ -158,30 +157,25 @@ export default function ProjectsPage() {
|
|||
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
SSTC 唯一事实链路
|
||||
PMFlow OS 项目入口
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-700">全链路强绑定,无断点闭环</p>
|
||||
<p className="text-sm font-medium text-slate-700">对话提取、变更确认、项目记忆、执行桥和发布增长分层推进。</p>
|
||||
</div>
|
||||
<CardDescription className="mt-2 text-slate-500">
|
||||
任何项目相关操作必须纳入完整链路;游离在链路外的信息不具备执行效力。
|
||||
项目索引只负责选项目和分流,具体确认、执行、发布与沉淀都回到 PMFlow OS 对应主流程。
|
||||
</CardDescription>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{CHAIN_STAGES.map((stage) => {
|
||||
const Icon = stage.icon;
|
||||
return (
|
||||
<div key={stage.key} className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white">
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="text-xs font-semibold">{stage.label}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-0.5 rounded-full bg-slate-200">
|
||||
<div className="h-full w-2/3 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
["PMFlow 主链", "对话、结构化提取、变更确认、记忆写入"],
|
||||
["项目工作台", "需求、决策、风险、任务对象图谱"],
|
||||
["开发执行", "Prompt Package、仓库、PR、执行回写"],
|
||||
["发布与增长", "发布门禁、反馈回流、复盘迭代"],
|
||||
].map(([label, summary]) => (
|
||||
<div key={label} className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<div className="text-xs font-semibold text-slate-800">{label}</div>
|
||||
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-slate-500">{summary}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -278,25 +272,25 @@ export default function ProjectsPage() {
|
|||
)}
|
||||
<div className="space-y-2">
|
||||
<Button asChild size="sm" className="w-full bg-blue-600 text-white hover:bg-blue-700">
|
||||
<Link to={`/projects/${p.id}/board`}>
|
||||
进入项目控制面
|
||||
<Link to={`/pmflow-os?projectId=${p.id}`}>
|
||||
进入 PMFlow 主流程
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button asChild size="sm" variant="outline" className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50">
|
||||
<Link to={`/projects/${p.id}/board?tab=ai-control`}>
|
||||
开发协同
|
||||
<Link to={`/work-graph?projectId=${p.id}`}>
|
||||
工作图谱
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50">
|
||||
<Link to={`/projects/${p.id}/board?tab=delivery`}>
|
||||
验证复盘
|
||||
<Link to={`/execution-bridge?projectId=${p.id}`}>
|
||||
开发执行
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50">
|
||||
<Link to={`/projects/${p.id}/board?tab=operating`}>
|
||||
经营合规
|
||||
<Link to={`/release-growth?projectId=${p.id}`}>
|
||||
发布增长
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ export default function SkillsPage() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能标签管理</h1>
|
||||
<p className="text-muted-foreground">维护技能标签分类并查看关联人数</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">能力词库</h1>
|
||||
<p className="text-muted-foreground">高级设置入口;标签只作为执行能力图谱的参考信号,不再作为任务分配中心。</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
|
|
|
|||
|
|
@ -275,8 +275,8 @@ export default function UsersPage() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">成员与权限</h1>
|
||||
<p className="text-muted-foreground">高级设置入口;成员只作为执行能力、判断责任和审查责任的底层信号。</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
getTeamWorkloads,
|
||||
type AIOptimizeProposal,
|
||||
} from "@/api/workloads";
|
||||
import { getPMFlowCapacityOverrides, getPMFlowCapacityPlan } from "@/api/pmflowOs";
|
||||
import type { PMFlowDailyCapacityPlan, PMFlowMemberCapacityPlan } from "@/features/pmflow-os/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RoleBadge } from "@/components/ui/RoleBadge";
|
||||
|
|
@ -44,12 +46,88 @@ function progressClass(value: number): string {
|
|||
return "bg-green-400";
|
||||
}
|
||||
|
||||
function CapacityPlanSummary({
|
||||
plan,
|
||||
isLoading,
|
||||
error,
|
||||
projectId,
|
||||
overrideCount,
|
||||
}: {
|
||||
plan?: PMFlowDailyCapacityPlan;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
projectId: string;
|
||||
overrideCount: number;
|
||||
}) {
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-slate-200 bg-white p-4 text-sm text-muted-foreground">
|
||||
输入项目编号后可查看今日容量计划和负责人手工调整记录;传统工时仍保留为历史成本参考。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return <div className="mb-4 rounded-xl border border-slate-200 bg-white p-4 text-sm text-muted-foreground">正在生成今日容量计划…</div>;
|
||||
}
|
||||
if (error || !plan) {
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-orange-200 bg-orange-50 p-4 text-sm leading-6 text-orange-700">
|
||||
今日容量计划暂时不可用;本页不会用旧负载百分比冒充自适应容量结论。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mb-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>今日容量计划</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<CapacityMetric label="团队有效容量" value={`${plan.teamEffectiveMinutes}min`} />
|
||||
<CapacityMetric label="已计划触点" value={`${plan.teamPlannedTouchMinutes.total}min`} />
|
||||
<CapacityMetric label="成员计划" value={`${plan.memberPlans.length}人`} />
|
||||
<CapacityMetric label="手工调整" value={`${overrideCount}条`} />
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-slate-50 p-3 text-sm leading-6 text-slate-600">
|
||||
动态建议结构:深度判断 {percent(plan.teamDemandMix.focus)} · 审查 {percent(plan.teamDemandMix.review)} · 验收 {percent(plan.teamDemandMix.acceptance)} · 协调 {percent(plan.teamDemandMix.coordination)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>容量解释</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm leading-6 text-slate-600">
|
||||
{(plan.recommendations.length ? plan.recommendations : ["当前没有真实人工触点,容量计划保持空态。"]).slice(0, 3).map((item) => (
|
||||
<p key={item}>{item}</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapacityMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
export default function WorkloadsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canOptimize = canManageTeamWorkload(user?.role);
|
||||
const { toast } = useToast();
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [searchParams] = useSearchParams();
|
||||
const [projectId, setProjectId] = useState(searchParams.get("projectId") ?? "");
|
||||
const [range, setRange] = useState<"week" | "month" | "custom">("week");
|
||||
const [customFrom, setCustomFrom] = useState("");
|
||||
const [customTo, setCustomTo] = useState("");
|
||||
|
|
@ -61,9 +139,24 @@ export default function WorkloadsPage() {
|
|||
queryFn: () => getTeamWorkloads({ project_id: projectId ? Number(projectId) : undefined }),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const capacityPlanQuery = useQuery({
|
||||
queryKey: ["pmflow-os", "capacity-plan", projectId],
|
||||
queryFn: () => getPMFlowCapacityPlan(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
const capacityOverridesQuery = useQuery({
|
||||
queryKey: ["pmflow-os", "capacity-overrides", projectId],
|
||||
queryFn: () => getPMFlowCapacityOverrides(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const summary = data?.summary;
|
||||
const capacityByUser = useMemo(() => {
|
||||
const map = new Map<string, PMFlowMemberCapacityPlan>();
|
||||
capacityPlanQuery.data?.memberPlans.forEach((member) => map.set(member.userId, member));
|
||||
return map;
|
||||
}, [capacityPlanQuery.data]);
|
||||
const hasOverloaded = (summary?.overloaded_count ?? 0) > 0;
|
||||
const hasQueryError = !!error;
|
||||
|
||||
|
|
@ -76,18 +169,22 @@ export default function WorkloadsPage() {
|
|||
max_weekly_hours: r.max_weekly_hours,
|
||||
workload_ratio: r.workload_ratio,
|
||||
remaining_hours: r.remaining_hours,
|
||||
capacity_status: capacityByUser.get(String(r.user_id))?.capacityStatus ?? "未接入",
|
||||
today_remaining_minutes: capacityByUser.get(String(r.user_id))?.remainingMinutes ?? null,
|
||||
})),
|
||||
[items]
|
||||
[capacityByUser, items]
|
||||
);
|
||||
|
||||
function exportExcelLike() {
|
||||
const headers = ["成员", "角色", "已分配工时", "每周上限", "负载比例%", "剩余工时"];
|
||||
const headers = ["成员", "角色", "今日容量状态", "今日剩余分钟", "已分配工时", "每周上限", "负载比例%", "剩余工时"];
|
||||
const lines = [
|
||||
headers.join("\t"),
|
||||
...exportRows.map((row) =>
|
||||
[
|
||||
row.member,
|
||||
row.role,
|
||||
row.capacity_status,
|
||||
row.today_remaining_minutes == null ? "未接入" : String(row.today_remaining_minutes),
|
||||
row.assigned_hours.toFixed(1),
|
||||
row.max_weekly_hours.toFixed(1),
|
||||
row.workload_ratio.toFixed(1),
|
||||
|
|
@ -99,7 +196,7 @@ export default function WorkloadsPage() {
|
|||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `团队负载报告-${new Date().toISOString().slice(0, 10)}.xls`;
|
||||
a.download = `容量明细-${new Date().toISOString().slice(0, 10)}.xls`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -107,18 +204,19 @@ export default function WorkloadsPage() {
|
|||
function exportPdfLike() {
|
||||
const htmlRows = exportRows
|
||||
.map(
|
||||
(r) => `<tr><td>${r.member}</td><td>${r.role}</td><td>${r.assigned_hours.toFixed(1)}</td><td>${r.max_weekly_hours.toFixed(1)}</td><td>${r.workload_ratio.toFixed(1)}%</td><td>${r.remaining_hours.toFixed(1)}</td></tr>`
|
||||
(r) => `<tr><td>${r.member}</td><td>${r.role}</td><td>${r.capacity_status}</td><td>${r.today_remaining_minutes == null ? "未接入" : r.today_remaining_minutes}</td><td>${r.assigned_hours.toFixed(1)}</td><td>${r.max_weekly_hours.toFixed(1)}</td><td>${r.workload_ratio.toFixed(1)}%</td><td>${r.remaining_hours.toFixed(1)}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
const win = window.open("", "_blank");
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head><title>团队负载报告</title></head>
|
||||
<head><title>容量明细</title></head>
|
||||
<body>
|
||||
<h2>团队负载报告</h2>
|
||||
<h2>容量明细</h2>
|
||||
<p>工时只作为成本参考,AI 分配以执行能力图谱与自适应人类容量为准。</p>
|
||||
<table border="1" cellspacing="0" cellpadding="6">
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已分配工时</th><th>每周上限</th><th>负载比例%</th><th>剩余工时</th></tr></thead>
|
||||
<thead><tr><th>成员</th><th>角色</th><th>今日容量状态</th><th>今日剩余分钟</th><th>已分配工时</th><th>每周上限</th><th>负载比例%</th><th>剩余工时</th></tr></thead>
|
||||
<tbody>${htmlRows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
|
@ -161,15 +259,15 @@ export default function WorkloadsPage() {
|
|||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">团队工作负载</h1>
|
||||
<p className="text-muted-foreground">查看成员负载比例、剩余工时与过载告警</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">容量明细</h1>
|
||||
<p className="text-muted-foreground">工时只作为成本参考,AI 分配以执行能力图谱与自适应人类容量为准。</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!canOptimize || optimizePreviewMutation.isPending}
|
||||
onClick={() => optimizePreviewMutation.mutate()}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
AI 优化任务分配
|
||||
生成容量调整建议
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -198,6 +296,14 @@ export default function WorkloadsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<CapacityPlanSummary
|
||||
plan={capacityPlanQuery.data}
|
||||
isLoading={capacityPlanQuery.isLoading}
|
||||
error={capacityPlanQuery.error}
|
||||
projectId={projectId}
|
||||
overrideCount={capacityOverridesQuery.data?.length ?? 0}
|
||||
/>
|
||||
|
||||
{hasOverloaded && (
|
||||
<div className="mb-4 flex h-9 items-center rounded-md border-l-4 border-orange-400 bg-orange-50 px-3 text-sm text-orange-700">
|
||||
<AlertTriangle className="mr-2 h-4 w-4 shrink-0" />
|
||||
|
|
@ -265,7 +371,7 @@ export default function WorkloadsPage() {
|
|||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>团队负载明细</CardTitle>
|
||||
<CardTitle>容量明细</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
|
|
|||
|
|
@ -4,29 +4,28 @@ import { Sidebar } from "@/components/layout/Sidebar";
|
|||
import { RouteLoadingShell } from "@/components/layout/LazyBoundary";
|
||||
import { MobileProjectBottomNav, PMFlowOsHeader, ResponsiveAICopilot } from "@/features/pmflow-os/components/PMFlowOsFrame";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AuthBoundary, ManagerBoundary } from "@/auth/RouteBoundary";
|
||||
import Layout from "@/components/layout/Layout";
|
||||
import LoginPage from "@/pages/LoginPage";
|
||||
import { canManageTeamWorkload, isManagerRole } from "@/lib/rbac";
|
||||
|
||||
const ProjectsPage = lazy(() => import("@/pages/ProjectsPage"));
|
||||
const ProjectBoardPage = lazy(() => import("@/pages/ProjectBoardPage"));
|
||||
const CharterDetailPage = lazy(() => import("@/pages/CharterDetailPage"));
|
||||
const SkillsPage = lazy(() => import("@/pages/SkillsPage"));
|
||||
const UserProfilePage = lazy(() => import("@/pages/UserProfilePage"));
|
||||
const UsersPage = lazy(() => import("@/pages/UsersPage"));
|
||||
const SkillTasksPage = lazy(() => import("@/pages/SkillTasksPage"));
|
||||
const AIStewardPage = lazy(() => import("@/pages/AIStewardPage"));
|
||||
const AIConfigPage = lazy(() => import("@/pages/AIConfigPage"));
|
||||
const FeishuConfigPage = lazy(() => import("@/pages/FeishuConfigPage"));
|
||||
const WorkloadsPage = lazy(() => import("@/pages/WorkloadsPage"));
|
||||
const WorkloadDetailPage = lazy(() => import("@/pages/WorkloadDetailPage"));
|
||||
const UserSkillsPage = lazy(() => import("@/pages/UserSkillsPage"));
|
||||
const InboxPage = lazy(() => import("@/pages/InboxPage"));
|
||||
const TaskBriefCenterPage = lazy(() => import("@/pages/TaskBriefCenterPage"));
|
||||
const TaskBriefTemplatePage = lazy(() => import("@/pages/TaskBriefTemplatePage"));
|
||||
const TaskBriefRunsPage = lazy(() => import("@/pages/TaskBriefRunsPage"));
|
||||
const ContractSyncBoardPage = lazy(() => import("@/pages/ContractSyncBoardPage"));
|
||||
const ModelRoutingConfigPage = lazy(() => import("@/pages/ModelRoutingConfigPage"));
|
||||
const TeamAIPolicyPage = lazy(() => import("@/pages/TeamAIPolicyPage"));
|
||||
const AIRoutingConsolePage = lazy(() => import("@/pages/AIRoutingConsolePage"));
|
||||
const KnowledgeGraphContextBoardPage = lazy(() => import("@/pages/KnowledgeGraphContextBoardPage"));
|
||||
const MCPConfigPage = lazy(() => import("@/pages/MCPConfigPage"));
|
||||
const QueryRouteGovernancePage = lazy(() => import("@/pages/QueryRouteGovernancePage"));
|
||||
|
|
@ -62,16 +61,6 @@ function AuthGuard() {
|
|||
return <Layout />;
|
||||
}
|
||||
|
||||
function AuthOnlyGuard({ children }: { children: JSX.Element }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.token != null);
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated) {
|
||||
const redirect = location.pathname + location.search;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(redirect || "/command-center")}`} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function ProjectLaunchStandalone() {
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-white">
|
||||
|
|
@ -160,20 +149,44 @@ function UserSkillsGuard() {
|
|||
return <Navigate to={`/users/${user.id}/skills`} replace />;
|
||||
}
|
||||
|
||||
function ManagerOnlyGuard({ children }: { children: JSX.Element }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (!user?.id) return <Navigate to="/login" replace />;
|
||||
if (!isManagerRole(user.role)) {
|
||||
return <Navigate to="/ai-config" replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function FallbackRedirect() {
|
||||
const isAuthenticated = useAuthStore((state) => state.token != null);
|
||||
return <Navigate to={isAuthenticated ? "/command-center" : "/login"} replace />;
|
||||
}
|
||||
|
||||
export function getLegacyAIStewardRedirectPath(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
const tab = params.get("tab");
|
||||
if (tab === "create") return "/project-launch";
|
||||
if (tab === "engine" || tab === "feedback-center") return "/ai-config";
|
||||
return "/space";
|
||||
}
|
||||
|
||||
export function getLegacyProjectBoardRedirectPath(projectId?: string | null): string {
|
||||
const id = String(projectId || "").trim();
|
||||
return id ? `/pmflow-os?projectId=${encodeURIComponent(id)}` : "/space";
|
||||
}
|
||||
|
||||
export function getLegacyProjectMemoryRedirectPath(projectId?: string | null): string {
|
||||
const id = String(projectId || "").trim();
|
||||
return id ? `/project-memory?projectId=${encodeURIComponent(id)}` : "/space";
|
||||
}
|
||||
|
||||
function LegacyAIStewardRedirect() {
|
||||
const location = useLocation();
|
||||
return <Navigate to={getLegacyAIStewardRedirectPath(location.search)} replace />;
|
||||
}
|
||||
|
||||
function LegacyProjectBoardRedirect() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <Navigate to={getLegacyProjectBoardRedirectPath(id)} replace />;
|
||||
}
|
||||
|
||||
function LegacyProjectMemoryRedirect() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <Navigate to={getLegacyProjectMemoryRedirectPath(id)} replace />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
|
|
@ -186,11 +199,11 @@ export const router = createBrowserRouter(
|
|||
},
|
||||
{
|
||||
path: "/project-launch",
|
||||
element: <ProjectLaunchStandalone />,
|
||||
element: <AuthBoundary allowProjectLaunchDemo><ProjectLaunchStandalone /></AuthBoundary>,
|
||||
},
|
||||
{
|
||||
path: "/space",
|
||||
element: <PersonalSpaceStandalone />,
|
||||
element: <AuthBoundary><PersonalSpaceStandalone /></AuthBoundary>,
|
||||
},
|
||||
{
|
||||
path: "/command-center",
|
||||
|
|
@ -198,26 +211,28 @@ export const router = createBrowserRouter(
|
|||
},
|
||||
{
|
||||
path: "/pmflow-os",
|
||||
element: <CommandCenterStandalone />,
|
||||
element: <AuthBoundary><CommandCenterStandalone /></AuthBoundary>,
|
||||
},
|
||||
{
|
||||
path: "/structured-extraction",
|
||||
element: <StructuredExtractionStandalone />,
|
||||
element: <AuthBoundary><StructuredExtractionStandalone /></AuthBoundary>,
|
||||
},
|
||||
{
|
||||
path: "/change-review",
|
||||
element: <ChangeReviewStandalone />,
|
||||
element: <AuthBoundary><ChangeReviewStandalone /></AuthBoundary>,
|
||||
},
|
||||
{
|
||||
path: "/memory-writeback-result",
|
||||
element: <MemoryWritebackStandalone />,
|
||||
element: <AuthBoundary><MemoryWritebackStandalone /></AuthBoundary>,
|
||||
},
|
||||
{
|
||||
path: "/me",
|
||||
element: (
|
||||
<div className="min-h-screen bg-white">
|
||||
{renderLazyPage(AccountPage)}
|
||||
</div>
|
||||
<AuthBoundary>
|
||||
<div className="min-h-screen bg-white">
|
||||
{renderLazyPage(AccountPage)}
|
||||
</div>
|
||||
</AuthBoundary>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -237,33 +252,36 @@ export const router = createBrowserRouter(
|
|||
{ path: "dashboard", element: <WorkloadsEntryGuard /> },
|
||||
{ path: "workloads", element: <WorkloadsEntryGuard /> },
|
||||
{ path: "workloads/:id", element: <WorkloadDetailGuard /> },
|
||||
{ path: "inbox", element: renderLazyPage(InboxPage) },
|
||||
{ path: "projects", element: renderLazyPage(ProjectsPage) },
|
||||
{ path: "projects/:id/board", element: renderLazyPage(ProjectBoardPage) },
|
||||
{ path: "projects/:id/charter/:charterId", element: renderLazyPage(CharterDetailPage) },
|
||||
{ path: "ai-steward", element: renderLazyPage(AIStewardPage) },
|
||||
{ path: "ai-config", element: renderLazyPage(AIConfigPage) },
|
||||
{ path: "ai-config/task-brief-center", element: renderLazyPage(TaskBriefCenterPage) },
|
||||
{ path: "ai-config/task-brief-templates", element: renderLazyPage(TaskBriefTemplatePage) },
|
||||
{ path: "ai-config/task-brief-runs", element: renderLazyPage(TaskBriefRunsPage) },
|
||||
{ path: "ai-config/contract-sync-board", element: renderLazyPage(ContractSyncBoardPage) },
|
||||
{ path: "ai-config/mcp-config", element: <ManagerOnlyGuard>{renderLazyPage(MCPConfigPage)}</ManagerOnlyGuard> },
|
||||
{ path: "ai-config/query-route-governance", element: <ManagerOnlyGuard>{renderLazyPage(QueryRouteGovernancePage)}</ManagerOnlyGuard> },
|
||||
{ path: "ai-config/model-routing-config", element: renderLazyPage(ModelRoutingConfigPage) },
|
||||
{ path: "ai-config/knowledge-graph-board", element: renderLazyPage(KnowledgeGraphContextBoardPage) },
|
||||
{ path: "ai-config/semantic-facts-board", element: <ManagerOnlyGuard>{renderLazyPage(SemanticFactsBoardPage)}</ManagerOnlyGuard> },
|
||||
{ path: "projects/:id/board", element: <LegacyProjectBoardRedirect /> },
|
||||
{ path: "projects/:id/charter/:charterId", element: <LegacyProjectMemoryRedirect /> },
|
||||
{ path: "ai-steward", element: <LegacyAIStewardRedirect /> },
|
||||
{ path: "ai-config", element: <ManagerBoundary>{renderLazyPage(AIConfigPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/task-brief-center", element: <ManagerBoundary>{renderLazyPage(TaskBriefCenterPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/task-brief-templates", element: <ManagerBoundary>{renderLazyPage(TaskBriefTemplatePage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/task-brief-runs", element: <ManagerBoundary>{renderLazyPage(TaskBriefRunsPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/contract-sync-board", element: <ManagerBoundary>{renderLazyPage(ContractSyncBoardPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/mcp-config", element: <ManagerBoundary>{renderLazyPage(MCPConfigPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/query-route-governance", element: <ManagerBoundary>{renderLazyPage(QueryRouteGovernancePage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/team-ai-policy", element: <ManagerBoundary>{renderLazyPage(TeamAIPolicyPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/ai-routing-console", element: <ManagerBoundary>{renderLazyPage(AIRoutingConsolePage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/model-routing-config", element: <ManagerBoundary>{renderLazyPage(ModelRoutingConfigPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/knowledge-graph-board", element: <ManagerBoundary>{renderLazyPage(KnowledgeGraphContextBoardPage)}</ManagerBoundary> },
|
||||
{ path: "ai-config/semantic-facts-board", element: <ManagerBoundary>{renderLazyPage(SemanticFactsBoardPage)}</ManagerBoundary> },
|
||||
{ path: "task-brief-center", element: <Navigate to="/ai-config/task-brief-center" replace /> },
|
||||
{ path: "task-brief-templates", element: <Navigate to="/ai-config/task-brief-templates" replace /> },
|
||||
{ path: "task-brief-runs", element: <Navigate to="/ai-config/task-brief-runs" replace /> },
|
||||
{ path: "contract-sync-board", element: <Navigate to="/ai-config/contract-sync-board" replace /> },
|
||||
{ path: "mcp-config", element: <Navigate to="/ai-config/mcp-config" replace /> },
|
||||
{ path: "query-route-governance", element: <Navigate to="/ai-config/query-route-governance" replace /> },
|
||||
{ path: "team-ai-policy", element: <Navigate to="/ai-config/team-ai-policy" replace /> },
|
||||
{ path: "ai-routing-console", element: <Navigate to="/ai-config/ai-routing-console" replace /> },
|
||||
{ path: "model-routing-config", element: <Navigate to="/ai-config/model-routing-config" replace /> },
|
||||
{ path: "knowledge-graph-board", element: <Navigate to="/ai-config/knowledge-graph-board" replace /> },
|
||||
{ path: "semantic-facts-board", element: <Navigate to="/ai-config/semantic-facts-board" replace /> },
|
||||
{ path: "feishu-config", element: renderLazyPage(FeishuConfigPage) },
|
||||
{ path: "skills", element: renderLazyPage(SkillsPage) },
|
||||
{ path: "users", element: renderLazyPage(UsersPage) },
|
||||
{ path: "feishu-config", element: <ManagerBoundary>{renderLazyPage(FeishuConfigPage)}</ManagerBoundary> },
|
||||
{ path: "skills", element: <ManagerBoundary>{renderLazyPage(SkillsPage)}</ManagerBoundary> },
|
||||
{ path: "users", element: <ManagerBoundary>{renderLazyPage(UsersPage)}</ManagerBoundary> },
|
||||
{ path: "users/:id/profile", element: renderLazyPage(UserProfilePage) },
|
||||
{ path: "users/:id/skills", element: <UserSkillsGuard /> },
|
||||
{ path: "users/:id/skill-tasks/:skillId", element: renderLazyPage(SkillTasksPage) },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getLegacyAIStewardRedirectPath,
|
||||
getLegacyProjectBoardRedirectPath,
|
||||
getLegacyProjectMemoryRedirectPath,
|
||||
} from "@/router";
|
||||
|
||||
describe("legacy AI Steward route migration", () => {
|
||||
it("routes old project creation to the new project launch", () => {
|
||||
expect(getLegacyAIStewardRedirectPath("?tab=create")).toBe("/project-launch");
|
||||
});
|
||||
|
||||
it("routes old operations tabs to AI operations center", () => {
|
||||
expect(getLegacyAIStewardRedirectPath("?tab=engine")).toBe("/ai-config");
|
||||
expect(getLegacyAIStewardRedirectPath("?tab=feedback-center")).toBe("/ai-config");
|
||||
});
|
||||
|
||||
it("routes bare and other legacy tabs to personal space", () => {
|
||||
expect(getLegacyAIStewardRedirectPath("")).toBe("/space");
|
||||
expect(getLegacyAIStewardRedirectPath("?tab=qa")).toBe("/space");
|
||||
expect(getLegacyAIStewardRedirectPath("?tab=signals")).toBe("/space");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy project route migration", () => {
|
||||
it("routes old project boards to the PMFlow OS main loop", () => {
|
||||
expect(getLegacyProjectBoardRedirectPath("17")).toBe("/pmflow-os?projectId=17");
|
||||
});
|
||||
|
||||
it("routes old charter detail pages to project memory", () => {
|
||||
expect(getLegacyProjectMemoryRedirectPath("17")).toBe("/project-memory?projectId=17");
|
||||
});
|
||||
|
||||
it("falls back to space when the legacy project id is missing", () => {
|
||||
expect(getLegacyProjectBoardRedirectPath("")).toBe("/space");
|
||||
expect(getLegacyProjectMemoryRedirectPath(null)).toBe("/space");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import type { AIPMOApprovalRequirement, AIPMORequirementPlanContextSummary, AIPMOWorkItem } from "@/api/aiPmo";
|
||||
import type { MountedAIRunItem } from "@/api/mountedAi";
|
||||
import type { NotificationItem } from "@/api/notifications";
|
||||
|
||||
export type InboxSourceKind = "notification" | "ai_pmo_work_item" | "mounted_ai_run";
|
||||
export type InboxDisplayKind = "action_required" | "auto_running" | "auto_done" | "info";
|
||||
export type InboxSectionKey = "all" | InboxDisplayKind;
|
||||
export type InboxActionKind =
|
||||
| "confirm"
|
||||
| "revise_draft"
|
||||
| "cancel_draft"
|
||||
| "reject"
|
||||
| "open_detail"
|
||||
| "open_target"
|
||||
| "mark_read"
|
||||
| "none";
|
||||
|
||||
export interface InboxActionDescriptor {
|
||||
kind: InboxActionKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface InboxItemViewModel {
|
||||
id: string;
|
||||
source_id: string;
|
||||
source_kind: InboxSourceKind;
|
||||
display_kind: InboxDisplayKind;
|
||||
title: string;
|
||||
summary: string;
|
||||
ai_plan?: string | null;
|
||||
blocking_reason?: string | null;
|
||||
primary_action: InboxActionDescriptor;
|
||||
secondary_action: InboxActionDescriptor;
|
||||
weak_action: InboxActionDescriptor;
|
||||
project_id?: number | null;
|
||||
project_name?: string | null;
|
||||
priority: string;
|
||||
priority_rank: number;
|
||||
is_read: boolean;
|
||||
dedupe_count: number;
|
||||
can_undo: boolean;
|
||||
undo_deadline?: string | null;
|
||||
target_url?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
stage_text?: string | null;
|
||||
next_step_text?: string | null;
|
||||
latest_policy_gate_code?: string | null;
|
||||
approval_requirement?: AIPMOApprovalRequirement | null;
|
||||
plan_mode?: boolean;
|
||||
plan_status?: string | null;
|
||||
requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null;
|
||||
source_notification?: NotificationItem;
|
||||
source_work_item?: AIPMOWorkItem;
|
||||
source_mounted_run?: MountedAIRunItem;
|
||||
}
|
||||
|
||||
export interface InboxSection {
|
||||
key: InboxSectionKey;
|
||||
label: string;
|
||||
count: number;
|
||||
items: InboxItemViewModel[];
|
||||
}
|
||||
|
||||
export interface LeaderSummary {
|
||||
decision_count: number;
|
||||
auto_running_count: number;
|
||||
auto_done_today: number;
|
||||
high_priority_blocked_count: number;
|
||||
external_authorization_count: number;
|
||||
top_decisions: InboxItemViewModel[];
|
||||
recent_completions: InboxItemViewModel[];
|
||||
}
|
||||
|
||||
export interface InboxBuildResult {
|
||||
items: InboxItemViewModel[];
|
||||
sections: InboxSection[];
|
||||
previewSections: InboxSection[];
|
||||
leaderSummary: LeaderSummary;
|
||||
}
|
||||
|
|
@ -63,34 +63,6 @@ export default defineConfig(({ mode }) => {
|
|||
return "markdown";
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes("/src/pages/ai-steward/") ||
|
||||
normalizedId.includes("/src/components/ai-steward/")
|
||||
) {
|
||||
return "ai-steward";
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes("/src/pages/project-board/") ||
|
||||
normalizedId.includes("/src/pages/charter-detail/") ||
|
||||
normalizedId.includes("/src/components/sstc/") ||
|
||||
normalizedId.includes("/src/components/project-control/") ||
|
||||
normalizedId.includes("/src/components/model-capability/") ||
|
||||
normalizedId.includes("/src/components/budget/")
|
||||
) {
|
||||
return "sstc";
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes("/src/pages/ai-config/") ||
|
||||
normalizedId.includes("/src/pages/query-route-governance/") ||
|
||||
normalizedId.includes("/src/pages/semantic-facts-board/") ||
|
||||
normalizedId.includes("/src/pages/model-routing-config/") ||
|
||||
normalizedId.includes("/src/pages/knowledge-graph-context-board/")
|
||||
) {
|
||||
return "governance";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue