chore(pmflow): integrate big module lane workbench
This commit is contained in:
parent
f95bd15ff3
commit
4b2193e828
12
AGENTS.md
12
AGENTS.md
|
|
@ -206,10 +206,14 @@ frontend/ # React 前端
|
|||
|
||||
## 17. Codex 并行开发治理
|
||||
|
||||
- 多个 Codex 线程并行推进时,执行单位必须是 `Packet`,不得以模糊“大模块”并行开工。
|
||||
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / owner_thread / base_ref / read_scope / write_scope / forbidden_paths / shared_files / depends_on / merge_after / verification / docs_to_sync`。
|
||||
- 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。
|
||||
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / module_lane / owner_thread / lane_owner / integration_owner / base_ref / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / depends_on / merge_after / verification / docs_to_sync`。
|
||||
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
|
||||
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
|
||||
- 大模块并行时,`Shell / Integration owner` 负责共享路由、导航、Provider、共享 API base、共享文档和最终合并;模块线程只能写自己的 owned files。
|
||||
- `Space` 线程只做个人空间聚合、草案箱、待确认摘要、AI 进行中摘要和跳转,不直接写 Project Memory、ChangeSet commit 或 Execution Bridge 回写。
|
||||
- `Account Settings` 线程只做个人资料、偏好、通知、账户壳和设置入口,不直接改项目主链、权限核心、AI 路由或 Space 聚合。
|
||||
- `Main Flow` 线程继续推进 PMFlow OS 主链,但不得顺手重做 Space 或个人中心。
|
||||
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
|
||||
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
|
||||
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
|
||||
|
|
@ -225,7 +229,11 @@ frontend/ # React 前端
|
|||
- `backend/app/schemas/*.py` 中的跨域 schema
|
||||
- `backend/alembic/versions/*`
|
||||
- `frontend/src/router/index.tsx`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/api/base.ts`
|
||||
- `frontend/src/lib/api.ts`
|
||||
- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`
|
||||
- `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`
|
||||
- `frontend/src/features/pmflow-os/store.tsx`
|
||||
- `CLAUDE.md`
|
||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -9,7 +9,7 @@
|
|||
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
|
||||
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
|
||||
- 新增前端快回路约定:`L1 微调` 默认走 `verify:login-fast` 或 focused screenshot,`L2 视觉逻辑` 默认走 `verify:login-responsive` 或 `capture:login:matrix`,出现 `context_compacted` 或超过 `8` 分钟仍未落代码时优先收口或切线程。
|
||||
- 新增并行开发约定:多个 Codex 线程并行推进时必须按 Packet 分派,声明 `read_scope / write_scope / forbidden_paths / shared_files / base_ref`,默认使用 `codex/<packet_id>` 独立分支和独立 worktree;共享高冲突文件只能由 integration owner 统一合并。
|
||||
- 新增并行开发约定:多个 Codex 线程可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行仍必须按 Packet 分派,声明 `module_lane / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / base_ref`,默认使用 `codex/<packet_id>` 独立分支和独立 worktree;共享路由、导航、Provider、API base 和文档只能由 integration owner 统一合并。当前 P0 已冻结 route 归属:`/space` 归 Space,`/pmflow-os / /command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow,`/me` 归 Account Settings,`/settings` 仅为 reserved route、尚未实现。
|
||||
- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。
|
||||
|
||||
## 2. 项目定位
|
||||
|
|
@ -110,12 +110,13 @@ frontend/
|
|||
- 当前前端重点入口优先看:
|
||||
- `frontend/src/api/`(统一 API 封装)
|
||||
- `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏,登录后默认主入口已切到 `/command-center`,`/space`、`/project-launch`、`/pmflow-os` 与 `/me` 是可直接打开的个人空间 / 新项目启动台 / PMFlow OS 项目上下文 / 我的账号入口,`/command-center` 也提供免登录产品预览壳,其余后台重页面按 route-level lazy 进入)
|
||||
- `frontend/src/features/personal-space/`(个人空间首页;本地 mock data 驱动继续推进、待我确认、AI 正在推进、暂存想法、草案箱和新建项目入口)
|
||||
- `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary,承载继续推进、待确认、AI 正在推进、草案箱和最近项目入口)
|
||||
- `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机)
|
||||
- `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 调后端,显式 `?demo=1` 才启用本地演示状态机)
|
||||
- `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;C1 不实现 commit)
|
||||
- `frontend/src/features/account-settings/`(`/me` 个人中心与设置;通过 `frontend/src/api/accountSettings.ts` 管理个人资料、默认语言、AI 回答深度、证据展示偏好和通知偏好,移动端使用分组列表与 bottom sheet,不进入 auth core)
|
||||
- `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 与 ChangeSet `commit` 调后端,显式 `?demo=1` 才启用本地演示状态机)
|
||||
- `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1/C2 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer` 与 ChangeSet `commit`;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed)
|
||||
- `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed)
|
||||
- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,commit 仍未实现)
|
||||
- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,Phase C2 backend/frontend commit 已实现)
|
||||
- `frontend/src/pages/inbox/`(消息台)
|
||||
- `frontend/src/pages/ai-steward/`(AI 管家)
|
||||
- `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示)
|
||||
|
|
@ -131,8 +132,9 @@ frontend/
|
|||
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
|
||||
- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;AI Steward / AI Config 页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。
|
||||
- `frontend/src/features/project-launch/` 是新项目启动台独立产品域:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,`mockData.ts` 只在显式 demo 模式下使用;默认路径调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=<id>`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。
|
||||
- `frontend/src/features/personal-space/` 是空间层入口:`mockData.ts` 固化本地项目、待确认、AI 推进、暂存想法与草案箱数据,`PersonalSpacePage.tsx` 承接桌面空间首页与独立移动布局,`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。
|
||||
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` API,成功后 refetch truth-state;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 不会进入 Project Memory,commit 留给 Phase C2。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。
|
||||
- `frontend/src/features/personal-space/` 是空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 并生成继续推进、待确认、AI 正在推进、草案箱、最近项目摘要;点击项目/确认/草案只跳回 `/pmflow-os?projectId=<id>` 主流程处理,不在 Space 写 Project Memory 或 execution event。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。
|
||||
- `frontend/src/features/account-settings/` 是 Account Settings 独立产品域:`AccountSettingsPanel.tsx` 承接 `/me` 桌面设置页、移动端分组列表与 bottom sheet,`frontend/src/api/accountSettings.ts` 定义个人资料、默认语言、AI 回答深度、证据展示偏好和四类通知偏好合同,当前首版使用浏览器本地持久化作为前端合同落点,不改 OAuth/JWT/auth core;`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 仅做薄入口并复用既有退出登录 bottom sheet。
|
||||
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 与 ChangeSet `commit` API,成功后 refetch truth-state;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。
|
||||
- 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。
|
||||
|
||||
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
||||
|
|
|
|||
|
|
@ -807,6 +807,53 @@ P0 公共壳冻结已完成,下一批可以进入 `Batch P1` 三模块并行
|
|||
|
||||
## 8. 进度台账
|
||||
|
||||
### 2026-04-26 / big-module-integration-p1 / done
|
||||
|
||||
完成内容:
|
||||
|
||||
- 作为 `Shell / Integration owner` 收口 Batch P1 三条 lane:Main Flow D.2、Space V1 Workbench Hardening、Account Settings V1。
|
||||
- 已确认当前分支为 `codex/main-flow-phase-d2-execution-handoff-contract`,Main Flow D.2 已落提交 `f95bd15 feat(pmflow-os): add execution handoff contract`,本轮不重写 D.2,只验证其与 Space / Account dirty worktree 的组合。
|
||||
- 已检查 tracked diff 与 untracked files,当前脏文件均落在本次集成范围:`AGENTS.md / CLAUDE.md / TASK_BRIEF_TEMPLATE.md / ARCHITECTURE_INVENTORY.md / personal-space / account-settings / accountSettings.ts / space.ts / AccountPage.tsx`。
|
||||
- Space 改动符合 P0 边界:`frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS `truth-state / execution-summary`,待确认项只跳转 `/change-review`,不直接写 Project Memory、ChangeSet 状态或 Execution Bridge event。
|
||||
- Account Settings 改动符合 P0 边界:`/me` 由 `AccountSettingsPanel` 承接个人资料、AI 偏好和通知偏好,首版只做浏览器本机持久化,不改 OAuth/JWT/auth core、router、MobileBottomNav、PMFlow OS store 或项目主链。
|
||||
- P0 文档残留已收口:`AGENTS.md / CLAUDE.md / TASK_BRIEF_TEMPLATE.md` 的大模块 lane、integration owner、共享文件锁与 Packet 模板规则可纳入 integration commit;`ARCHITECTURE_INVENTORY.md` 修正旧的 Space 只能消费 mock data / 禁止接后端描述,改为当前只读聚合合同。
|
||||
- `CODE_REFERENCE_INDEX.md` 已确认包含 Space API、Account Settings API、PMFlow OS D.2 execution handoff API 索引;`CLAUDE.md` 已确认不再保留旧的“commit 未实现”描述。
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
|
||||
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- `docs/architecture/TASK_BRIEF_TEMPLATE.md`
|
||||
- `frontend/src/api/space.ts`
|
||||
- `frontend/src/api/accountSettings.ts`
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.tsx`
|
||||
- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`
|
||||
- `frontend/src/features/personal-space/components/SpaceChrome.tsx`
|
||||
- `frontend/src/features/account-settings/AccountSettingsPanel.tsx`
|
||||
- `frontend/src/features/account-settings/accountSettings.test.ts`
|
||||
- `frontend/src/features/pmflow-os/pages/AccountPage.tsx`
|
||||
|
||||
验证:
|
||||
|
||||
- `cd frontend && npm run test -- src/features/personal-space/PersonalSpacePage.test.tsx` 通过:`1 passed`。
|
||||
- `cd frontend && npm run test -- src/features/account-settings/accountSettings.test.ts` 通过:`3 passed`。
|
||||
- `cd frontend && npm run test -- src/features/pmflow-os` 通过:`4 passed`。
|
||||
- `cd frontend && npm run typecheck` 通过。
|
||||
- `cd frontend && npm run build` 通过;Vite 仍输出既有 manual chunk circular warning(`ai-steward / sstc / charts / governance`),未阻断构建。
|
||||
- `PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q` 通过:`9 passed`;仍有既有 `backend/app/schemas/recipe.py` Pydantic V2 class-based config deprecation warning。
|
||||
|
||||
未完成或风险:
|
||||
|
||||
- 本 integration packet 未新增或修改后端运行时代码、Schema 或 migration;本轮未执行热部署/重启。当前验证发生在本地开发工作区,未连接实际部署服务;若要让 Main Flow D.2 的后端提交在部署环境生效,需要在对应部署机执行 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart` 或等价发布流程。
|
||||
- Space 的 `savedIdeas` 后端只读聚合合同仍未实现;当前 UI 明确展示 empty/warning,不使用 mock 数据冒充真实内容。
|
||||
- Account Settings V1 仍是前端合同 + 本机持久化;跨设备同步需要后续独立新增 `account_settings_api` 与持久化表。
|
||||
|
||||
下一步建议:
|
||||
|
||||
- 进入 D.3:补 Dev Sync 写回闭环,把 Codex / Local Bridge / GitHub agent 的 branch、commit、PR、test progress event 回写到 `execution-summary / truth-state`。
|
||||
|
||||
### 2026-04-26 / Space V1 Workbench Hardening / implemented
|
||||
|
||||
完成内容:
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@
|
|||
- AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。
|
||||
- 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。
|
||||
- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。
|
||||
- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页与新项目启动台同样是独立产品域,只能分别消费 `frontend/src/features/personal-space/` 的本地 mock data 与 `frontend/src/features/project-launch/` 的本地状态机,禁止在首版中接后端、真实 AI 调用、真实项目创建 API、团队空间强切换或完整企业组织管理流程。
|
||||
- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页、新项目启动台和 Account Settings 同样是独立产品域:Space 只能通过 `frontend/src/api/space.ts` 做项目摘要、PMFlow OS `truth-state / execution-summary` 的只读聚合并跳回主流程;Project Launch 默认走真实 draft/confirm 创建链,只有显式 demo 参数才使用本地状态机;Account Settings 只管理个人资料、偏好和通知,不改 OAuth/JWT/auth core、PMFlow OS truth store、Project Memory、ChangeSet 或 Execution Bridge 写回。
|
||||
|
||||
## 6. 工具与运行库存
|
||||
- `backend/scripts/repair_requirement_plan_history.py`
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
|
||||
本模板用于 PMFlow Codex 单线程或并行 Packet 分派。并行任务必须填写 `Read Scope / Write Scope / Forbidden Paths`;没有 `write_scope` 的任务不允许并行执行。
|
||||
|
||||
大模块并行时,`Module Lane` 表示长期产品表面归属,例如 `Shell / Integration`、`Main Flow / PMFlow OS`、`Space`、`Account Settings`。`Packet` 仍是实际执行单位;不能只填写模块名后直接开工。
|
||||
|
||||
## Meta
|
||||
|
||||
- Packet ID: `{{PACKET_ID}}`
|
||||
- Module Lane: `Shell / Integration | Main Flow / PMFlow OS | Space | Account Settings | Other`
|
||||
- Phase: `{{PHASE}}`
|
||||
- Owner: `{{OWNER}}`
|
||||
- Lane Owner: `{{LANE_OWNER}}`
|
||||
- Integration Owner: `{{INTEGRATION_OWNER}}`
|
||||
- Generated At: `{{GENERATED_AT}}`
|
||||
- Base Ref: `{{BASE_REF}}`
|
||||
- Head Ref: `{{HEAD_REF}}`
|
||||
|
|
@ -28,6 +33,23 @@
|
|||
|
||||
{{TARGET_OBJECT_OR_CONTRACT}}
|
||||
|
||||
## Module Boundary
|
||||
|
||||
- Lane Responsibility:
|
||||
{{LANE_RESPONSIBILITY}}
|
||||
|
||||
- Cross-lane Inputs:
|
||||
{{CROSS_LANE_INPUTS}}
|
||||
|
||||
- Cross-lane Outputs:
|
||||
{{CROSS_LANE_OUTPUTS}}
|
||||
|
||||
- Shared Shell / Route / Navigation Impact:
|
||||
{{SHELL_ROUTE_NAV_IMPACT}}
|
||||
|
||||
- If shared files are needed, owner approval / dependency:
|
||||
{{SHARED_OWNER_APPROVAL_OR_DEPENDENCY}}
|
||||
|
||||
## Scope
|
||||
|
||||
- Read Scope:
|
||||
|
|
@ -76,6 +98,7 @@
|
|||
## Integration Notes
|
||||
|
||||
- Integration Owner: `{{INTEGRATION_OWNER}}`
|
||||
- Module Lane Merge Group: `{{MODULE_LANE_MERGE_GROUP}}`
|
||||
- Merge Order: `{{MERGE_ORDER}}`
|
||||
- Expected Commit(s): `{{EXPECTED_COMMITS}}`
|
||||
- Out-of-scope Findings:
|
||||
|
|
@ -86,6 +109,9 @@
|
|||
- [ ] `base_ref` recorded
|
||||
- [ ] `read_scope` and `write_scope` declared
|
||||
- [ ] `forbidden_paths` checked
|
||||
- [ ] Module lane declared
|
||||
- [ ] Cross-lane inputs/outputs declared
|
||||
- [ ] Shared shell/router/nav impact declared
|
||||
- [ ] Shared files reserved or assigned to integration owner
|
||||
- [ ] Code completed
|
||||
- [ ] Contracts synchronized
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
export type AccountLanguage = "zh-CN" | "en-US";
|
||||
export type AccountAnswerDepth = "concise" | "balanced" | "deep";
|
||||
export type AccountEvidenceDisplay = "summary" | "inline" | "full";
|
||||
export type AccountNotificationKey = "pendingConfirmation" | "executionWriteback" | "releaseGate" | "failureAlert";
|
||||
export type AccountSettingsSaveScope = "local" | "account";
|
||||
|
||||
export interface AccountProfileSettings {
|
||||
displayName: string;
|
||||
roleTitle: string;
|
||||
email: string;
|
||||
defaultLanguage: AccountLanguage;
|
||||
}
|
||||
|
||||
export interface AccountAiPreferenceSettings {
|
||||
answerDepth: AccountAnswerDepth;
|
||||
evidenceDisplay: AccountEvidenceDisplay;
|
||||
}
|
||||
|
||||
export interface AccountNotificationPreference {
|
||||
enabled: boolean;
|
||||
desktop: boolean;
|
||||
emailDigest: boolean;
|
||||
}
|
||||
|
||||
export type AccountNotificationSettings = Record<AccountNotificationKey, AccountNotificationPreference>;
|
||||
|
||||
export interface AccountSettings {
|
||||
profile: AccountProfileSettings;
|
||||
aiPreferences: AccountAiPreferenceSettings;
|
||||
notifications: AccountNotificationSettings;
|
||||
saveScope: AccountSettingsSaveScope;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountSettingsSeed {
|
||||
displayName?: string | null;
|
||||
roleTitle?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "pmflow:account-settings:v1";
|
||||
|
||||
export const accountNotificationLabels: Record<AccountNotificationKey, { title: string; detail: string }> = {
|
||||
pendingConfirmation: {
|
||||
title: "待确认",
|
||||
detail: "AI 生成草案、变更项或写入前确认需要你拍板时提醒。",
|
||||
},
|
||||
executionWriteback: {
|
||||
title: "执行回写",
|
||||
detail: "Agent、IDE 或集成系统回传开发进度、分支、PR 与测试结果时提醒。",
|
||||
},
|
||||
releaseGate: {
|
||||
title: "发布门禁",
|
||||
detail: "发布校验、合规检查、验收项阻塞或通过时提醒。",
|
||||
},
|
||||
failureAlert: {
|
||||
title: "失败提醒",
|
||||
detail: "AI 调用、同步任务、执行回写或外部集成失败时提醒。",
|
||||
},
|
||||
};
|
||||
|
||||
export const accountNotificationKeys: AccountNotificationKey[] = [
|
||||
"pendingConfirmation",
|
||||
"executionWriteback",
|
||||
"releaseGate",
|
||||
"failureAlert",
|
||||
];
|
||||
|
||||
export function createDefaultAccountSettings(seed: AccountSettingsSeed = {}): AccountSettings {
|
||||
return {
|
||||
profile: {
|
||||
displayName: seed.displayName?.trim() || "张子墨",
|
||||
roleTitle: seed.roleTitle?.trim() || "产品负责人",
|
||||
email: seed.email?.trim() || "",
|
||||
defaultLanguage: "zh-CN",
|
||||
},
|
||||
aiPreferences: {
|
||||
answerDepth: "balanced",
|
||||
evidenceDisplay: "inline",
|
||||
},
|
||||
notifications: {
|
||||
pendingConfirmation: { enabled: true, desktop: true, emailDigest: false },
|
||||
executionWriteback: { enabled: true, desktop: true, emailDigest: false },
|
||||
releaseGate: { enabled: true, desktop: true, emailDigest: true },
|
||||
failureAlert: { enabled: true, desktop: true, emailDigest: true },
|
||||
},
|
||||
saveScope: "local",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isBrowserStorageAvailable(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function readStoredAccountSettings(): Partial<AccountSettings> | null {
|
||||
if (!isBrowserStorageAvailable()) return null;
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<AccountSettings>;
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistAccountSettings(settings: AccountSettings): void {
|
||||
if (!isBrowserStorageAvailable()) {
|
||||
throw new Error("当前环境不支持本机暂存");
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings {
|
||||
if (!stored) return base;
|
||||
return {
|
||||
...base,
|
||||
...stored,
|
||||
profile: {
|
||||
...base.profile,
|
||||
...stored.profile,
|
||||
},
|
||||
aiPreferences: {
|
||||
...base.aiPreferences,
|
||||
...stored.aiPreferences,
|
||||
},
|
||||
notifications: {
|
||||
...base.notifications,
|
||||
...stored.notifications,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> {
|
||||
const defaults = createDefaultAccountSettings(seed);
|
||||
return mergeAccountSettings(defaults, readStoredAccountSettings());
|
||||
}
|
||||
|
||||
export async function updateAccountSettings(nextSettings: AccountSettings): Promise<AccountSettings> {
|
||||
const persisted = {
|
||||
...nextSettings,
|
||||
saveScope: "local" as const,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
persistAccountSettings(persisted);
|
||||
return persisted;
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
import { getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowExecutionSummary } from "./pmflowOs";
|
||||
import { projectsApi, type ProjectSummary } from "./projects";
|
||||
import type {
|
||||
ChangeItem,
|
||||
PMFlowExecutionStatus,
|
||||
PMFlowObjectStatus,
|
||||
ProjectTruthState,
|
||||
} from "@/features/pmflow-os/types";
|
||||
|
||||
export interface SpaceWorkbenchProject {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
stage: string;
|
||||
progress: number;
|
||||
pendingCount: number;
|
||||
activeTaskCount: number;
|
||||
riskLabel: string;
|
||||
nextAction: string;
|
||||
updatedAt: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SpacePendingConfirmation {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
source: "changeset" | "approval";
|
||||
stage: string;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
createdAt: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceAIProgressItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: PMFlowExecutionStatus | "active" | "synced";
|
||||
progress: number;
|
||||
updatedAt: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceDraftItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
objectType: string;
|
||||
status: PMFlowObjectStatus;
|
||||
updatedAt: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceSavedIdea {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SpaceWorkbench {
|
||||
projects: SpaceWorkbenchProject[];
|
||||
pendingConfirmations: SpacePendingConfirmation[];
|
||||
aiProgress: SpaceAIProgressItem[];
|
||||
savedIdeas: SpaceSavedIdea[];
|
||||
drafts: SpaceDraftItem[];
|
||||
recentProjects: SpaceWorkbenchProject[];
|
||||
warnings: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface ProjectSnapshot {
|
||||
project: ProjectSummary;
|
||||
truth: ProjectTruthState | null;
|
||||
execution: PMFlowExecutionSummary | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const ACTIVE_EXECUTION_STATUSES: Array<PMFlowExecutionStatus> = ["queued", "pushed", "coding", "pr_open", "testing"];
|
||||
|
||||
function stageLabel(stage?: string | null): string {
|
||||
const labels: Record<string, string> = {
|
||||
idea: "想法",
|
||||
validation: "验证",
|
||||
planning: "规划",
|
||||
development: "开发",
|
||||
testing: "测试",
|
||||
release: "发布",
|
||||
growth: "增长",
|
||||
};
|
||||
return labels[String(stage || "")] || "推进中";
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: "进行中",
|
||||
completed: "已完成",
|
||||
archived: "已归档",
|
||||
on_hold: "暂停",
|
||||
draft: "草案",
|
||||
confirmed: "已确认",
|
||||
in_progress: "进行中",
|
||||
blocked: "阻塞",
|
||||
done: "完成",
|
||||
released: "已发布",
|
||||
};
|
||||
return labels[String(status || "")] || "未同步";
|
||||
}
|
||||
|
||||
function riskLabel(truth: ProjectTruthState | null): string {
|
||||
if (!truth) return "未同步";
|
||||
const activeRisks = truth.risks.filter((risk) => risk.status !== "done" && risk.status !== "released");
|
||||
if (activeRisks.some((risk) => risk.level === "high")) return "高";
|
||||
if (activeRisks.some((risk) => risk.level === "medium")) return "中";
|
||||
if (activeRisks.length > 0) return "低";
|
||||
return "无";
|
||||
}
|
||||
|
||||
function objectTitle(value: unknown, fallback: string): string {
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const record = value as Record<string, unknown>;
|
||||
return String(record.title || record.name || record.headline || fallback);
|
||||
}
|
||||
|
||||
function objectSummary(value: unknown, fallback = "等待在主流程确认后写回事实层。"): string {
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const record = value as Record<string, unknown>;
|
||||
return String(record.summary || record.diff || record.description || fallback);
|
||||
}
|
||||
|
||||
function objectCreatedAt(value: unknown, fallback: string): string {
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const record = value as Record<string, unknown>;
|
||||
return String(record.createdAt || record.created_at || record.updatedAt || record.updated_at || fallback);
|
||||
}
|
||||
|
||||
function objectId(value: unknown, fallback: string): string {
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const record = value as Record<string, unknown>;
|
||||
return String(record.id || fallback);
|
||||
}
|
||||
|
||||
function objectRiskLevel(value: unknown): "low" | "medium" | "high" {
|
||||
if (!value || typeof value !== "object") return "medium";
|
||||
const raw = String((value as Record<string, unknown>).riskLevel || (value as Record<string, unknown>).risk_level || "");
|
||||
return raw === "high" || raw === "low" ? raw : "medium";
|
||||
}
|
||||
|
||||
function reviewHref(projectId: string, changeSetId?: string | null): string {
|
||||
const params = new URLSearchParams({ projectId });
|
||||
if (changeSetId) params.set("changeSetId", changeSetId);
|
||||
return `/change-review?${params.toString()}`;
|
||||
}
|
||||
|
||||
function projectHref(projectId: string): string {
|
||||
return `/pmflow-os?projectId=${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
function pendingChangeItems(truth: ProjectTruthState | null): ChangeItem[] {
|
||||
if (!truth) return [];
|
||||
return truth.changeItems.filter((item) => item.status === "pending_review");
|
||||
}
|
||||
|
||||
function deriveProject(snapshot: ProjectSnapshot): SpaceWorkbenchProject {
|
||||
const { project, truth, execution } = snapshot;
|
||||
const pending = pendingChangeItems(truth);
|
||||
const inProgressTasks = truth?.tasks.filter((task) => task.status === "in_progress") ?? [];
|
||||
const blockedTasks = truth?.tasks.filter((task) => task.status === "blocked") ?? [];
|
||||
const nextPending = pending[0];
|
||||
const nextTask = inProgressTasks[0] || blockedTasks[0] || truth?.tasks.find((task) => task.status === "draft");
|
||||
const currentStage = truth?.project.stage || (nextTask ? nextTask.stage : null);
|
||||
const nextAction = nextPending?.title || nextTask?.title || "进入项目主流程继续推进";
|
||||
|
||||
return {
|
||||
id: String(project.id),
|
||||
title: project.title || truth?.project.name || `项目 ${project.id}`,
|
||||
summary: project.description || truth?.project.goal || "暂无项目摘要",
|
||||
status: statusLabel(project.status),
|
||||
stage: stageLabel(currentStage),
|
||||
progress: Math.max(0, Math.min(100, Math.round(project.progress ?? 0))),
|
||||
pendingCount: pending.length + (truth?.approvals.length ?? 0),
|
||||
activeTaskCount: inProgressTasks.length + blockedTasks.length + (execution?.activeDevTasks.length ?? 0),
|
||||
riskLabel: riskLabel(truth),
|
||||
nextAction,
|
||||
updatedAt: project.updated_at || project.created_at,
|
||||
href: projectHref(String(project.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function derivePending(snapshot: ProjectSnapshot): SpacePendingConfirmation[] {
|
||||
const { project, truth } = snapshot;
|
||||
if (!truth) return [];
|
||||
const projectId = String(project.id);
|
||||
const projectTitle = project.title || truth.project.name || `项目 ${projectId}`;
|
||||
const changeItems = pendingChangeItems(truth).map((item) => ({
|
||||
id: `change-${item.id}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: item.title,
|
||||
summary: item.summary || item.diff || "等待在主流程确认变更。",
|
||||
source: "changeset" as const,
|
||||
stage: stageLabel(item.objectType),
|
||||
riskLevel: item.riskLevel,
|
||||
createdAt: objectCreatedAt(
|
||||
truth.changeSets.find((changeSet) => objectId(changeSet, "") === item.changeSetId),
|
||||
truth.lastWritebackAt || project.updated_at
|
||||
),
|
||||
href: reviewHref(projectId, item.changeSetId),
|
||||
}));
|
||||
const approvals = truth.approvals.map((approval, index) => ({
|
||||
id: `approval-${objectId(approval, `${projectId}-${index}`)}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: objectTitle(approval, "待确认审批"),
|
||||
summary: objectSummary(approval, "审批项需要回到项目主流程处理。"),
|
||||
source: "approval" as const,
|
||||
stage: stageLabel(approval.stage),
|
||||
riskLevel: objectRiskLevel(approval),
|
||||
createdAt: objectCreatedAt(approval, project.updated_at),
|
||||
href: reviewHref(projectId, truth.activeChangeSetId),
|
||||
}));
|
||||
return [...changeItems, ...approvals];
|
||||
}
|
||||
|
||||
function progressFromExecutionStatus(status: PMFlowExecutionStatus | "active" | "synced"): number {
|
||||
const values: Record<string, number> = {
|
||||
queued: 12,
|
||||
pushed: 28,
|
||||
coding: 52,
|
||||
pr_open: 72,
|
||||
testing: 84,
|
||||
synced: 100,
|
||||
failed: 100,
|
||||
active: 48,
|
||||
};
|
||||
return values[status] ?? 40;
|
||||
}
|
||||
|
||||
function deriveAIProgress(snapshot: ProjectSnapshot): SpaceAIProgressItem[] {
|
||||
const { project, truth, execution } = snapshot;
|
||||
const projectId = String(project.id);
|
||||
const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`;
|
||||
const href = projectHref(projectId);
|
||||
const items: SpaceAIProgressItem[] = [];
|
||||
|
||||
if (execution && ACTIVE_EXECUTION_STATUSES.includes(execution.execution.status)) {
|
||||
const status = execution.execution.status;
|
||||
items.push({
|
||||
id: `execution-${projectId}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: execution.execution.taskId ? `执行任务 ${execution.execution.taskId}` : "AI 正在推进执行链路",
|
||||
summary: execution.execution.prompt || execution.execution.testSummary || "正在聚合执行进度与回写状态。",
|
||||
status,
|
||||
progress: progressFromExecutionStatus(status),
|
||||
updatedAt: project.updated_at,
|
||||
href,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, event] of (execution?.recentProgressEvents ?? []).slice(0, 2).entries()) {
|
||||
items.push({
|
||||
id: `progress-${projectId}-${objectId(event, String(index))}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: objectTitle(event, "执行进度更新"),
|
||||
summary: objectSummary(event, "AI 执行进度已更新。"),
|
||||
status: "active",
|
||||
progress: 64,
|
||||
updatedAt: objectCreatedAt(event, project.updated_at),
|
||||
href,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, task] of (execution?.activeDevTasks ?? []).slice(0, 2).entries()) {
|
||||
items.push({
|
||||
id: `dev-task-${projectId}-${objectId(task, String(index))}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: objectTitle(task, "开发任务进行中"),
|
||||
summary: objectSummary(task, "开发 Agent 正在推进该任务。"),
|
||||
status: "active",
|
||||
progress: 58,
|
||||
updatedAt: objectCreatedAt(task, project.updated_at),
|
||||
href,
|
||||
});
|
||||
}
|
||||
|
||||
if (truth?.loopStage === "ai_extracting") {
|
||||
items.push({
|
||||
id: `extracting-${projectId}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: "AI 正在抽取项目事实",
|
||||
summary: "正在从会话与材料中生成待确认变更。",
|
||||
status: "active",
|
||||
progress: 42,
|
||||
updatedAt: project.updated_at,
|
||||
href,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function deriveDrafts(snapshot: ProjectSnapshot): SpaceDraftItem[] {
|
||||
const { project, truth } = snapshot;
|
||||
if (!truth) return [];
|
||||
const projectId = String(project.id);
|
||||
const projectTitle = project.title || truth.project.name || `项目 ${projectId}`;
|
||||
const href = projectHref(projectId);
|
||||
const draftObjects = [
|
||||
...truth.requirements.map((item) => ({ ...item, objectType: "需求" })),
|
||||
...truth.decisions.map((item) => ({ ...item, objectType: "决策" })),
|
||||
...truth.tasks.map((item) => ({ ...item, objectType: "任务" })),
|
||||
...truth.risks.map((item) => ({ ...item, objectType: "风险" })),
|
||||
...truth.modelDecisions.map((item) => ({ ...item, objectType: "模型决策" })),
|
||||
].filter((item) => item.status === "draft");
|
||||
|
||||
return draftObjects.map((item) => ({
|
||||
id: `${projectId}-${item.objectType}-${item.id}`,
|
||||
projectId,
|
||||
projectTitle,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
objectType: item.objectType,
|
||||
status: item.status,
|
||||
updatedAt: project.updated_at,
|
||||
href,
|
||||
}));
|
||||
}
|
||||
|
||||
function dateValue(item: { updatedAt?: string; updated_at?: string; createdAt?: string; created_at?: string }): number {
|
||||
return new Date(item.updatedAt || item.updated_at || item.createdAt || item.created_at || 0).getTime();
|
||||
}
|
||||
|
||||
function sortByDate<T extends { updatedAt?: string; updated_at?: string; createdAt?: string; created_at?: string }>(
|
||||
items: T[]
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => dateValue(b) - dateValue(a));
|
||||
}
|
||||
|
||||
async function getProjectSnapshot(project: ProjectSummary): Promise<ProjectSnapshot> {
|
||||
const projectId = String(project.id);
|
||||
const [truthResult, executionResult] = await Promise.allSettled([
|
||||
getPMFlowTruthState(projectId),
|
||||
getPMFlowExecutionSummary(projectId),
|
||||
]);
|
||||
const warnings: string[] = [];
|
||||
const truth = truthResult.status === "fulfilled" ? truthResult.value : null;
|
||||
const execution = executionResult.status === "fulfilled" ? executionResult.value : null;
|
||||
if (truthResult.status === "rejected") warnings.push(`${project.title}: 项目真相摘要未同步`);
|
||||
if (executionResult.status === "rejected") warnings.push(`${project.title}: 执行摘要未同步`);
|
||||
const projectionWarnings = truth
|
||||
? (truth as unknown as { projectionWarnings?: string[] }).projectionWarnings ?? []
|
||||
: [];
|
||||
if (projectionWarnings.length) warnings.push(...projectionWarnings.map((item) => `${project.title}: ${item}`));
|
||||
if (execution?.warnings.length) warnings.push(...execution.warnings.map((item) => `${project.title}: ${item}`));
|
||||
return { project, truth, execution, warnings };
|
||||
}
|
||||
|
||||
export const spaceApi = {
|
||||
async getWorkbench(): Promise<SpaceWorkbench> {
|
||||
const summary = await projectsApi.getSummary({ status: "active" });
|
||||
const projects = sortByDate(summary.projects).slice(0, 8);
|
||||
const snapshots = await Promise.all(projects.map(getProjectSnapshot));
|
||||
const workbenchProjects = snapshots.map(deriveProject);
|
||||
return {
|
||||
projects: workbenchProjects.slice(0, 4),
|
||||
pendingConfirmations: sortByDate(snapshots.flatMap(derivePending)).slice(0, 8),
|
||||
aiProgress: sortByDate(snapshots.flatMap(deriveAIProgress)).slice(0, 8),
|
||||
savedIdeas: [],
|
||||
drafts: sortByDate(snapshots.flatMap(deriveDrafts)).slice(0, 8),
|
||||
recentProjects: sortByDate(workbenchProjects),
|
||||
warnings: [
|
||||
"暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。",
|
||||
...snapshots.flatMap((snapshot) => snapshot.warnings),
|
||||
].slice(0, 8),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,768 @@
|
|||
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Mail,
|
||||
Repeat2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
accountNotificationKeys,
|
||||
accountNotificationLabels,
|
||||
getAccountSettings,
|
||||
updateAccountSettings,
|
||||
type AccountAiPreferenceSettings,
|
||||
type AccountAnswerDepth,
|
||||
type AccountEvidenceDisplay,
|
||||
type AccountLanguage,
|
||||
type AccountNotificationKey,
|
||||
type AccountProfileSettings,
|
||||
type AccountSettings,
|
||||
type AccountSettingsSaveScope,
|
||||
} from "@/api/accountSettings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MobileBottomSheet } from "@/features/pmflow-os/components/FeedbackPrimitives";
|
||||
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
|
||||
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
type MobileSheetKey = "profile" | "ai" | "notifications" | null;
|
||||
|
||||
const answerDepthOptions: Array<{ value: AccountAnswerDepth; title: string; detail: string }> = [
|
||||
{ value: "concise", title: "简洁", detail: "只给结论和必要依据" },
|
||||
{ value: "balanced", title: "标准", detail: "结论、依据、下一步都保留" },
|
||||
{ value: "deep", title: "深入", detail: "展开证据链、风险和取舍" },
|
||||
];
|
||||
|
||||
const evidenceOptions: Array<{ value: AccountEvidenceDisplay; title: string; detail: string }> = [
|
||||
{ value: "summary", title: "摘要", detail: "只展示来源摘要" },
|
||||
{ value: "inline", title: "内联", detail: "关键结论旁展示证据" },
|
||||
{ value: "full", title: "完整", detail: "保留来源、片段和验证状态" },
|
||||
];
|
||||
|
||||
const languageOptions: Array<{ value: AccountLanguage; title: string; detail: string }> = [
|
||||
{ value: "zh-CN", title: "中文", detail: "界面、AI 回复和通知默认中文" },
|
||||
{ value: "en-US", title: "English", detail: "English replies when product context allows it" },
|
||||
];
|
||||
|
||||
function getDisplayName(user: ReturnType<typeof useAuthStore.getState>["user"]) {
|
||||
return user?.name || user?.username || "张子墨";
|
||||
}
|
||||
|
||||
function getDisplayRole(user: ReturnType<typeof useAuthStore.getState>["user"]) {
|
||||
if (user?.role === "admin") return "管理员";
|
||||
if (user?.role === "manager") return "项目负责人";
|
||||
return user?.role || "产品负责人";
|
||||
}
|
||||
|
||||
function getAvatarSeed(user: ReturnType<typeof useAuthStore.getState>["user"], displayName: string) {
|
||||
return encodeURIComponent(user?.id || user?.username || displayName || "pmflow-account");
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "尚未保存";
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function AccountSettingsPanel({ onLogout }: { onLogout: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const seed = useMemo(
|
||||
() => ({
|
||||
displayName: getDisplayName(user),
|
||||
roleTitle: getDisplayRole(user),
|
||||
email: user?.email || "",
|
||||
}),
|
||||
[user]
|
||||
);
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ["account-settings", seed.displayName, seed.roleTitle, seed.email],
|
||||
queryFn: () => getAccountSettings(seed),
|
||||
});
|
||||
const [draft, setDraft] = useState<AccountSettings | null>(null);
|
||||
const [activeSheet, setActiveSheet] = useState<MobileSheetKey>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsQuery.data) setDraft(settingsQuery.data);
|
||||
}, [settingsQuery.data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: updateAccountSettings,
|
||||
onSuccess: (saved) => {
|
||||
setDraft(saved);
|
||||
queryClient.setQueryData(["account-settings", seed.displayName, seed.roleTitle, seed.email], saved);
|
||||
},
|
||||
});
|
||||
|
||||
const isDirty = Boolean(
|
||||
draft && settingsQuery.data && JSON.stringify(draft) !== JSON.stringify(settingsQuery.data)
|
||||
);
|
||||
const avatarSrc = `https://api.dicebear.com/7.x/avataaars/svg?seed=${getAvatarSeed(user, draft?.profile.displayName || seed.displayName)}`;
|
||||
|
||||
const patchProfile = (patch: Partial<AccountProfileSettings>) => {
|
||||
setDraft((current) => current ? { ...current, profile: { ...current.profile, ...patch } } : current);
|
||||
};
|
||||
const patchAi = (patch: Partial<AccountAiPreferenceSettings>) => {
|
||||
setDraft((current) => current ? { ...current, aiPreferences: { ...current.aiPreferences, ...patch } } : current);
|
||||
};
|
||||
const patchNotification = (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => {
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
notifications: {
|
||||
...current.notifications,
|
||||
[key]: { ...current.notifications[key], ...patch },
|
||||
},
|
||||
}
|
||||
: current
|
||||
);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!draft) return;
|
||||
saveMutation.mutate(draft);
|
||||
};
|
||||
const saveStatus: SaveStatus = saveMutation.isError
|
||||
? "failed"
|
||||
: draft?.saveScope === "account"
|
||||
? "account"
|
||||
: "local";
|
||||
|
||||
if (settingsQuery.isLoading || !draft) {
|
||||
return <AccountSettingsSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesktopAccountSettings
|
||||
avatarSrc={avatarSrc}
|
||||
draft={draft}
|
||||
isDirty={isDirty}
|
||||
isSaving={saveMutation.isPending}
|
||||
saveStatus={saveStatus}
|
||||
onLogout={onLogout}
|
||||
onPatchAi={patchAi}
|
||||
onPatchNotification={patchNotification}
|
||||
onPatchProfile={patchProfile}
|
||||
onSave={save}
|
||||
/>
|
||||
<MobileAccountSettings
|
||||
avatarSrc={avatarSrc}
|
||||
draft={draft}
|
||||
activeSheet={activeSheet}
|
||||
isDirty={isDirty}
|
||||
isSaving={saveMutation.isPending}
|
||||
saveStatus={saveStatus}
|
||||
onOpenSheet={setActiveSheet}
|
||||
onLogout={onLogout}
|
||||
onPatchAi={patchAi}
|
||||
onPatchNotification={patchNotification}
|
||||
onPatchProfile={patchProfile}
|
||||
onSave={save}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopAccountSettings({
|
||||
avatarSrc,
|
||||
draft,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveStatus,
|
||||
onLogout,
|
||||
onPatchAi,
|
||||
onPatchNotification,
|
||||
onPatchProfile,
|
||||
onSave,
|
||||
}: {
|
||||
avatarSrc: string;
|
||||
draft: AccountSettings;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
onLogout: () => void;
|
||||
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
|
||||
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
|
||||
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="hidden min-h-full bg-white md:block">
|
||||
<div className="mx-auto w-full max-w-[1180px] px-8 py-8 xl:max-w-[1320px]">
|
||||
<div className="mb-7 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#2463EB]">我的</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-slate-950">个人资料与偏好</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">这些设置会影响 PMFlow 的默认语言、AI 回复形态、证据展示和提醒节奏。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/space" className="rounded-lg border border-[#dfe7f3] px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-[#2463EB] hover:text-[#2463EB] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30">
|
||||
返回个人空间
|
||||
</Link>
|
||||
<Button
|
||||
className="h-10 rounded-lg bg-[#2463EB] px-5 font-semibold text-white hover:bg-[#1f58d5]"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
{getSaveButtonLabel({ isDirty, isSaving, saveStatus })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[340px_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
<ProfileSummary avatarSrc={avatarSrc} draft={draft} />
|
||||
<SaveStatusPanel saveStatus={saveStatus} />
|
||||
<section className="rounded-lg border border-[#e6edf7] bg-[#f8fbff] p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-[#2463EB]">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-950">账户操作边界</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">本页不修改 OAuth、JWT 或权限核心。退出登录只清理当前浏览器会话,草案、待确认和项目记忆仍按原链路保留。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-2">
|
||||
<ActionButton icon={<Repeat2 className="h-4 w-4" />} label="切换账号" detail="保留当前空间上下文" />
|
||||
<ActionButton icon={<LogOut className="h-4 w-4" />} label="退出登录" detail="AI 进度与待确认项保留" danger onClick={onLogout} />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main className="space-y-5">
|
||||
<SettingsSection icon={<UserRound className="h-5 w-5" />} title="个人资料" detail="用于 PMFlow 内的称呼、空间标识和通知展示。">
|
||||
<ProfileFields profile={draft.profile} onPatchProfile={onPatchProfile} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection icon={<Bot className="h-5 w-5" />} title="AI 偏好" detail="控制默认回答深度和证据呈现,不影响运行时模型路由。">
|
||||
<AiPreferenceFields aiPreferences={draft.aiPreferences} onPatchAi={onPatchAi} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection icon={<Bell className="h-5 w-5" />} title="通知偏好" detail="优先覆盖需要人类拍板、执行回写、发布门禁和失败提醒。">
|
||||
<NotificationFields notifications={draft.notifications} onPatchNotification={onPatchNotification} />
|
||||
</SettingsSection>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAccountSettings({
|
||||
avatarSrc,
|
||||
draft,
|
||||
activeSheet,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveStatus,
|
||||
onOpenSheet,
|
||||
onLogout,
|
||||
onPatchAi,
|
||||
onPatchNotification,
|
||||
onPatchProfile,
|
||||
onSave,
|
||||
}: {
|
||||
avatarSrc: string;
|
||||
draft: AccountSettings;
|
||||
activeSheet: MobileSheetKey;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
onOpenSheet: (key: MobileSheetKey) => void;
|
||||
onLogout: () => void;
|
||||
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
|
||||
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
|
||||
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-4 pb-32 pt-5 text-slate-950 md:hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<PMFlowBrand compact />
|
||||
<button type="button" className="rounded-lg border border-[#e6edf7] px-3 py-2 text-sm font-semibold text-slate-700">
|
||||
个人空间 <ChevronDown className="ml-1 inline h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-7">
|
||||
<ProfileSummary avatarSrc={avatarSrc} draft={draft} mobile />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<SaveStatusPanel saveStatus={saveStatus} mobile />
|
||||
</div>
|
||||
|
||||
<MobileGroup title="设置">
|
||||
<MobileSettingRow icon={<UserRound className="h-5 w-5" />} title="个人资料" detail={`${draft.profile.displayName} · ${draft.profile.roleTitle}`} onClick={() => onOpenSheet("profile")} />
|
||||
<MobileSettingRow icon={<SlidersHorizontal className="h-5 w-5" />} title="AI 偏好" detail={`${getAnswerDepthTitle(draft.aiPreferences.answerDepth)} · ${getEvidenceTitle(draft.aiPreferences.evidenceDisplay)}`} onClick={() => onOpenSheet("ai")} />
|
||||
<MobileSettingRow icon={<Bell className="h-5 w-5" />} title="通知偏好" detail={`${getEnabledNotificationCount(draft)} 类提醒已开启`} onClick={() => onOpenSheet("notifications")} />
|
||||
</MobileGroup>
|
||||
|
||||
<MobileGroup title="账号操作">
|
||||
<MobileSettingRow icon={<Repeat2 className="h-5 w-5" />} title="切换账号" detail="保留当前空间上下文" />
|
||||
<MobileSettingRow icon={<LogOut className="h-5 w-5" />} title="退出登录" detail="草案与待确认事项保留" danger onClick={onLogout} />
|
||||
</MobileGroup>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-[68px] z-40 border-t border-[#e6edf7] bg-white/95 px-4 py-3 backdrop-blur md:hidden">
|
||||
<Button
|
||||
className="h-12 w-full rounded-lg bg-[#2463EB] text-base font-semibold text-white hover:bg-[#1f58d5]"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
{getSaveButtonLabel({ isDirty, isSaving, saveStatus })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<MobileBottomNav />
|
||||
|
||||
<MobileBottomSheet
|
||||
open={activeSheet === "profile"}
|
||||
title="个人资料"
|
||||
detail="用于空间标识、通知展示和 AI 对你的默认称呼。"
|
||||
onClose={() => onOpenSheet(null)}
|
||||
>
|
||||
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
|
||||
<ProfileFields profile={draft.profile} onPatchProfile={onPatchProfile} mobile />
|
||||
</div>
|
||||
</MobileBottomSheet>
|
||||
|
||||
<MobileBottomSheet
|
||||
open={activeSheet === "ai"}
|
||||
title="AI 偏好"
|
||||
detail="偏好只影响 PMFlow 的默认回复形态,不改变模型路由和权限。"
|
||||
onClose={() => onOpenSheet(null)}
|
||||
>
|
||||
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
|
||||
<AiPreferenceFields aiPreferences={draft.aiPreferences} onPatchAi={onPatchAi} mobile />
|
||||
</div>
|
||||
</MobileBottomSheet>
|
||||
|
||||
<MobileBottomSheet
|
||||
open={activeSheet === "notifications"}
|
||||
title="通知偏好"
|
||||
detail="优先处理需要你拍板、发布受阻或执行链路异常的提醒。"
|
||||
onClose={() => onOpenSheet(null)}
|
||||
>
|
||||
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
|
||||
<NotificationFields notifications={draft.notifications} onPatchNotification={onPatchNotification} mobile />
|
||||
</div>
|
||||
</MobileBottomSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSummary({ avatarSrc, draft, mobile = false }: { avatarSrc: string; draft: AccountSettings; mobile?: boolean }) {
|
||||
return (
|
||||
<section className={cn("rounded-lg border border-[#e6edf7] bg-white p-5", !mobile && "shadow-[0_12px_34px_rgba(15,23,42,0.04)]")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={avatarSrc} alt={`${draft.profile.displayName}头像`} className="h-16 w-16 rounded-full bg-[#eef4ff]" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xl font-semibold text-slate-950">{draft.profile.displayName}</div>
|
||||
<div className="mt-1 truncate text-sm text-slate-500">{draft.profile.roleTitle} · 个人空间</div>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-[#eef4ff] px-3 py-1 text-xs font-semibold text-[#2463EB]">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{draft.profile.defaultLanguage === "zh-CN" ? "默认中文" : "Default English"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 border-t border-[#eef2f8] pt-4 text-xs text-slate-500">上次保存:{formatUpdatedAt(draft.updatedAt)}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ icon, title, detail, children }: { icon: ReactNode; title: string; detail: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
||||
<div className="mb-5 flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#eef4ff] text-[#2463EB]">{icon}</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-500">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveStatus = AccountSettingsSaveScope | "failed";
|
||||
|
||||
function getSaveStatusCopy(saveStatus: SaveStatus): {
|
||||
title: string;
|
||||
detail: string;
|
||||
wrap: string;
|
||||
iconWrap: string;
|
||||
titleClass: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
} {
|
||||
if (saveStatus === "account") {
|
||||
return {
|
||||
title: "已保存到当前账号",
|
||||
detail: "这些偏好已和当前登录身份绑定,后续可由系统能力消费。",
|
||||
wrap: "border-[#cfe0ff] bg-[#eef4ff]",
|
||||
iconWrap: "bg-white text-[#2463EB]",
|
||||
titleClass: "text-[#2459d3]",
|
||||
Icon: ShieldCheck,
|
||||
};
|
||||
}
|
||||
if (saveStatus === "failed") {
|
||||
return {
|
||||
title: "保存失败",
|
||||
detail: "当前偏好还没有写入本机暂存。请重试,或检查浏览器存储权限。",
|
||||
wrap: "border-rose-100 bg-rose-50",
|
||||
iconWrap: "bg-white text-rose-600",
|
||||
titleClass: "text-rose-700",
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "仅本机暂存",
|
||||
detail: "当前 V1 不写云端账号设置;刷新本机浏览器会保留,换设备不会同步。",
|
||||
wrap: "border-[#e6edf7] bg-[#f8fbff]",
|
||||
iconWrap: "bg-white text-slate-500",
|
||||
titleClass: "text-slate-700",
|
||||
Icon: Check,
|
||||
};
|
||||
}
|
||||
|
||||
function getSaveButtonLabel({
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveStatus,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
}) {
|
||||
if (isSaving) return "保存中";
|
||||
if (isDirty && saveStatus === "failed") return "重试保存";
|
||||
if (isDirty) return "保存设置";
|
||||
if (saveStatus === "account") return "已保存到当前账号";
|
||||
if (saveStatus === "failed") return "保存失败";
|
||||
return "仅本机暂存";
|
||||
}
|
||||
|
||||
function SaveStatusPanel({ saveStatus, mobile = false }: { saveStatus: SaveStatus; mobile?: boolean }) {
|
||||
const status = getSaveStatusCopy(saveStatus);
|
||||
return (
|
||||
<section className={cn("rounded-lg border px-4 py-3 text-sm", status.wrap, !mobile && "shadow-[0_12px_34px_rgba(15,23,42,0.03)]")}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg", status.iconWrap)}>
|
||||
<status.Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={cn("font-semibold", status.titleClass)}>{status.title}</div>
|
||||
<div className="mt-1 leading-6 text-slate-500">{status.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileFields({
|
||||
profile,
|
||||
mobile = false,
|
||||
onPatchProfile,
|
||||
}: {
|
||||
profile: AccountProfileSettings;
|
||||
mobile?: boolean;
|
||||
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid gap-4", !mobile && "sm:grid-cols-2")}>
|
||||
<LabeledControl label="显示名称">
|
||||
<Input
|
||||
value={profile.displayName}
|
||||
onChange={(event) => onPatchProfile({ displayName: event.target.value })}
|
||||
className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30"
|
||||
/>
|
||||
</LabeledControl>
|
||||
<LabeledControl label="角色称谓">
|
||||
<Input
|
||||
value={profile.roleTitle}
|
||||
onChange={(event) => onPatchProfile({ roleTitle: event.target.value })}
|
||||
className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30"
|
||||
/>
|
||||
</LabeledControl>
|
||||
<LabeledControl label="邮箱">
|
||||
<div className="relative">
|
||||
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
value={profile.email}
|
||||
onChange={(event) => onPatchProfile({ email: event.target.value })}
|
||||
className="h-11 rounded-lg border-[#dfe7f3] pl-9 focus-visible:ring-[#2463EB]/30"
|
||||
placeholder="用于通知摘要"
|
||||
/>
|
||||
</div>
|
||||
</LabeledControl>
|
||||
<LabeledControl label="默认语言">
|
||||
<SegmentedOptions
|
||||
options={languageOptions}
|
||||
value={profile.defaultLanguage}
|
||||
onChange={(value) => onPatchProfile({ defaultLanguage: value })}
|
||||
/>
|
||||
</LabeledControl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiPreferenceFields({
|
||||
aiPreferences,
|
||||
mobile = false,
|
||||
onPatchAi,
|
||||
}: {
|
||||
aiPreferences: AccountAiPreferenceSettings;
|
||||
mobile?: boolean;
|
||||
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid gap-5", !mobile && "xl:grid-cols-2")}>
|
||||
<LabeledControl label="AI 回答深度">
|
||||
<SegmentedOptions
|
||||
options={answerDepthOptions}
|
||||
value={aiPreferences.answerDepth}
|
||||
onChange={(value) => onPatchAi({ answerDepth: value })}
|
||||
/>
|
||||
</LabeledControl>
|
||||
<LabeledControl label="证据展示偏好">
|
||||
<SegmentedOptions
|
||||
options={evidenceOptions}
|
||||
value={aiPreferences.evidenceDisplay}
|
||||
onChange={(value) => onPatchAi({ evidenceDisplay: value })}
|
||||
/>
|
||||
</LabeledControl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationFields({
|
||||
notifications,
|
||||
mobile = false,
|
||||
onPatchNotification,
|
||||
}: {
|
||||
notifications: AccountSettings["notifications"];
|
||||
mobile?: boolean;
|
||||
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{accountNotificationKeys.map((key) => {
|
||||
const item = notifications[key];
|
||||
const label = accountNotificationLabels[key];
|
||||
return (
|
||||
<div key={key} className={cn("rounded-lg border border-[#e6edf7] bg-white p-4", mobile && "px-3")}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold text-slate-950">{label.title}</div>
|
||||
<div className="mt-1 text-sm leading-6 text-slate-500">{label.detail}</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(enabled) => onPatchNotification(key, { enabled })}
|
||||
className="mt-1 data-[state=checked]:bg-[#2463EB]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<ChannelToggle
|
||||
label="桌面提醒"
|
||||
checked={item.desktop}
|
||||
disabled={!item.enabled}
|
||||
onChange={(desktop) => onPatchNotification(key, { desktop })}
|
||||
/>
|
||||
<ChannelToggle
|
||||
label="邮件摘要"
|
||||
checked={item.emailDigest}
|
||||
disabled={!item.enabled}
|
||||
onChange={(emailDigest) => onPatchNotification(key, { emailDigest })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelToggle({
|
||||
label,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={cn("flex min-h-10 items-center justify-between rounded-lg border px-3 text-sm font-semibold", disabled ? "border-[#eef2f8] bg-slate-50 text-slate-400" : "border-[#e6edf7] bg-[#f8fbff] text-slate-700")}>
|
||||
{label}
|
||||
<Switch checked={checked} disabled={disabled} onCheckedChange={onChange} className="data-[state=checked]:bg-[#2463EB]" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedOptions<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: Array<{ value: T; title: string; detail: string }>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"flex min-h-[58px] w-full items-center justify-between gap-3 rounded-lg border px-4 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30",
|
||||
selected ? "border-[#2463EB] bg-[#eef4ff] text-[#174cba]" : "border-[#e6edf7] bg-white text-slate-700 hover:border-[#cfe0ff] hover:bg-[#f8fbff]"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{option.title}</span>
|
||||
<span className={cn("mt-1 block text-xs leading-5", selected ? "text-[#2459d3]" : "text-slate-500")}>{option.detail}</span>
|
||||
</span>
|
||||
<span className={cn("flex h-5 w-5 shrink-0 items-center justify-center rounded-full border", selected ? "border-[#2463EB] bg-[#2463EB] text-white" : "border-[#d5deea] text-transparent")}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledControl({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-slate-700">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
detail: string;
|
||||
danger?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30",
|
||||
danger ? "border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100" : "border-[#e6edf7] bg-white text-slate-800 hover:border-[#cfe0ff]"
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", danger ? "bg-white text-rose-500" : "bg-[#eef4ff] text-[#2463EB]")}>{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold">{label}</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">{detail}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileGroup({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mt-7">
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-950">{title}</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSettingRow({
|
||||
icon,
|
||||
title,
|
||||
detail,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
detail?: string;
|
||||
danger?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-h-[64px] w-full items-center justify-between gap-3 rounded-lg border px-4 text-left transition active:scale-[0.99]",
|
||||
danger ? "border-rose-200 bg-rose-50 text-rose-600" : "border-[#e6edf7] bg-white text-slate-900"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-lg", danger ? "bg-white text-rose-500" : "bg-[#eef4ff] text-[#2463EB]")}>{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-base font-semibold">{title}</span>
|
||||
{detail ? <span className="mt-0.5 block truncate text-sm text-slate-500">{detail}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className={danger ? "text-rose-300" : "text-slate-300"}>›</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSettingsSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-5 py-8">
|
||||
<div className="mx-auto max-w-[1180px] animate-pulse">
|
||||
<div className="h-8 w-48 rounded-lg bg-slate-100" />
|
||||
<div className="mt-4 h-4 w-80 max-w-full rounded bg-slate-100" />
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-[340px_minmax(0,1fr)]">
|
||||
<div className="h-56 rounded-lg bg-slate-100" />
|
||||
<div className="space-y-5">
|
||||
<div className="h-52 rounded-lg bg-slate-100" />
|
||||
<div className="h-52 rounded-lg bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEnabledNotificationCount(settings: AccountSettings) {
|
||||
return accountNotificationKeys.filter((key) => settings.notifications[key].enabled).length;
|
||||
}
|
||||
|
||||
function getAnswerDepthTitle(value: AccountAnswerDepth) {
|
||||
return answerDepthOptions.find((option) => option.value === value)?.title || "标准";
|
||||
}
|
||||
|
||||
function getEvidenceTitle(value: AccountEvidenceDisplay) {
|
||||
return evidenceOptions.find((option) => option.value === value)?.title || "内联";
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createDefaultAccountSettings, getAccountSettings, updateAccountSettings } from "@/api/accountSettings";
|
||||
|
||||
describe("account settings api", () => {
|
||||
const storageSetItem = Storage.prototype.setItem;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
Storage.prototype.setItem = storageSetItem;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.prototype.setItem = storageSetItem;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates defaults from the current account seed", async () => {
|
||||
const settings = await getAccountSettings({
|
||||
displayName: "陈雨",
|
||||
roleTitle: "独立开发者",
|
||||
email: "yu@example.com",
|
||||
});
|
||||
|
||||
expect(settings.profile.displayName).toBe("陈雨");
|
||||
expect(settings.profile.roleTitle).toBe("独立开发者");
|
||||
expect(settings.profile.email).toBe("yu@example.com");
|
||||
expect(settings.saveScope).toBe("local");
|
||||
expect(settings.aiPreferences.answerDepth).toBe("balanced");
|
||||
expect(settings.notifications.pendingConfirmation.enabled).toBe(true);
|
||||
expect(settings.notifications.executionWriteback.enabled).toBe(true);
|
||||
expect(settings.notifications.releaseGate.enabled).toBe(true);
|
||||
expect(settings.notifications.failureAlert.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("persists updated preferences through the api layer", async () => {
|
||||
const defaults = createDefaultAccountSettings({ displayName: "陈雨" });
|
||||
const saved = await updateAccountSettings({
|
||||
...defaults,
|
||||
profile: { ...defaults.profile, defaultLanguage: "en-US" },
|
||||
aiPreferences: { answerDepth: "deep", evidenceDisplay: "full" },
|
||||
notifications: {
|
||||
...defaults.notifications,
|
||||
failureAlert: { enabled: false, desktop: false, emailDigest: false },
|
||||
},
|
||||
});
|
||||
const restored = await getAccountSettings({ displayName: "陈雨" });
|
||||
|
||||
expect(saved.updatedAt).toBeTruthy();
|
||||
expect(saved.saveScope).toBe("local");
|
||||
expect(restored.profile.defaultLanguage).toBe("en-US");
|
||||
expect(restored.aiPreferences.answerDepth).toBe("deep");
|
||||
expect(restored.aiPreferences.evidenceDisplay).toBe("full");
|
||||
expect(restored.notifications.failureAlert.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("fails explicitly when local persistence is unavailable", async () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("storage denied");
|
||||
});
|
||||
|
||||
await expect(updateAccountSettings(createDefaultAccountSettings())).rejects.toThrow("storage denied");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PersonalSpacePage from "./PersonalSpacePage";
|
||||
import { spaceApi, type SpaceWorkbench } from "@/api/space";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
vi.mock("@/api/space", () => ({
|
||||
spaceApi: {
|
||||
getWorkbench: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/features/pmflow-os/components/MobileBottomNav", () => ({
|
||||
MobileBottomNav: () => <nav aria-label="mobile bottom nav" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/pmflow-os/components/AccountControls", () => ({
|
||||
PMFlowAccountActions: () => <div data-testid="account-actions" />,
|
||||
}));
|
||||
|
||||
const emptyWorkbench: SpaceWorkbench = {
|
||||
projects: [],
|
||||
pendingConfirmations: [],
|
||||
aiProgress: [],
|
||||
savedIdeas: [],
|
||||
drafts: [],
|
||||
recentProjects: [],
|
||||
warnings: ["暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。"],
|
||||
generatedAt: "2026-04-26T08:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("PersonalSpacePage", () => {
|
||||
it("renders the read-only Space workbench sections without demo saved ideas", async () => {
|
||||
vi.mocked(spaceApi.getWorkbench).mockResolvedValue(emptyWorkbench);
|
||||
|
||||
renderWithProviders(<PersonalSpacePage />, ["/space"]);
|
||||
|
||||
expect(await screen.findAllByText("暂无可继续推进的项目")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("个人工作面")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("继续推进")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("待我确认")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("AI 正在推进")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("暂存想法")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("草案箱")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("最近项目")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("暂无暂存想法")).not.toHaveLength(0);
|
||||
expect(screen.getByText("暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("AI 周报助手")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,12 +21,12 @@ import { PMFlowBrandHeader } from "@/features/pmflow-os/components/chrome";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const sidebarMain = [
|
||||
{ label: "空间首页", icon: Home, badge: undefined },
|
||||
{ label: "最近项目", icon: ClockIcon, badge: undefined },
|
||||
{ label: "暂存想法", icon: MessageCircle, badge: 8 },
|
||||
{ label: "草案箱", icon: FileBox, badge: 5 },
|
||||
{ label: "待我确认", icon: CheckCircle2, badge: 12 },
|
||||
{ label: "AI 正在推进", icon: Sparkles, badge: 6 },
|
||||
{ label: "空间首页", icon: Home, metricKey: undefined },
|
||||
{ label: "最近项目", icon: ClockIcon, metricKey: "recentProjects" },
|
||||
{ label: "暂存想法", icon: MessageCircle, metricKey: "savedIdeas" },
|
||||
{ label: "草案箱", icon: FileBox, metricKey: "drafts" },
|
||||
{ label: "待我确认", icon: CheckCircle2, metricKey: "pendingConfirmations" },
|
||||
{ label: "AI 正在推进", icon: Sparkles, metricKey: "aiProgress" },
|
||||
];
|
||||
|
||||
const sidebarTools = [
|
||||
|
|
@ -45,12 +45,20 @@ function ClockIcon(props: SVGProps<SVGSVGElement>) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SpaceSidebar({ activeLabel = "空间首页" }: { activeLabel?: string }) {
|
||||
export function SpaceSidebar({
|
||||
activeLabel = "空间首页",
|
||||
metrics,
|
||||
}: {
|
||||
activeLabel?: string;
|
||||
metrics?: Partial<Record<"recentProjects" | "savedIdeas" | "drafts" | "pendingConfirmations" | "aiProgress", number>>;
|
||||
}) {
|
||||
return (
|
||||
<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) => (
|
||||
{sidebarMain.map((item) => {
|
||||
const badge = item.metricKey ? metrics?.[item.metricKey] : undefined;
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
|
|
@ -63,9 +71,10 @@ export function SpaceSidebar({ activeLabel = "空间首页" }: { activeLabel?: s
|
|||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge ? <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{item.badge}</span> : null}
|
||||
{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]" />
|
||||
<nav className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -1,124 +1,14 @@
|
|||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
LogOut,
|
||||
PanelTop,
|
||||
Repeat2,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AccountListItem,
|
||||
AccountProfileSummary,
|
||||
MobileLogoutSheet,
|
||||
} from "@/features/pmflow-os/components/AccountControls";
|
||||
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
|
||||
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
|
||||
import { AccountSettingsPanel } from "@/features/account-settings/AccountSettingsPanel";
|
||||
import { MobileLogoutSheet } from "@/features/pmflow-os/components/AccountControls";
|
||||
|
||||
export default function AccountPage() {
|
||||
const [logoutSheetOpen, setLogoutSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden min-h-full bg-white md:block">
|
||||
<div className="mx-auto max-w-[980px] px-8 py-8">
|
||||
<div className="mb-7 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.04em] text-slate-950">账号与空间</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">管理当前身份动作。退出登录不会改变项目层级,也不会中断 AI 正在推进的工作。</p>
|
||||
</div>
|
||||
<Link to="/space" className="rounded-xl border border-[#dfe7f3] px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-[#2463EB] hover:text-[#2463EB]">
|
||||
返回个人空间
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<AccountProfileSummary />
|
||||
<section className="rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#eef4ff] text-[#2463EB]">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">退出规则</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">AI 生成内容保持待确认状态,只有你确认后才会写入项目记忆。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 text-sm text-slate-600">
|
||||
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">AI 推进不会中断,进度会保留在个人空间。</div>
|
||||
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">草案和待确认事项不会被当作已确认事实。</div>
|
||||
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">已确认的项目记忆不受退出登录影响。</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<section className="space-y-3 rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">账号</h2>
|
||||
<AccountListItem icon={UserRound} title="个人资料" detail="姓名、角色与默认头像" />
|
||||
<AccountListItem icon={Bell} title="通知设置" detail="待确认、AI 推进和项目动态提醒" />
|
||||
<AccountListItem icon={PanelTop} title="空间偏好" detail="默认个人空间与页面显示偏好" />
|
||||
</section>
|
||||
<section className="space-y-3 rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">支持与账号操作</h2>
|
||||
<AccountListItem icon={CircleHelp} title="帮助与反馈" detail="查看帮助文档或提交建议" />
|
||||
<AccountListItem icon={Repeat2} title="切换账号" detail="保留当前项目上下文,切换另一个身份" />
|
||||
<AccountListItem icon={LogOut} title="退出登录" detail="退出当前身份,保留空间内草案与待确认事项" danger onClick={() => setLogoutSheetOpen(true)} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileAccountPage onLogout={() => setLogoutSheetOpen(true)} />
|
||||
<AccountSettingsPanel onLogout={() => setLogoutSheetOpen(true)} />
|
||||
<MobileLogoutSheet open={logoutSheetOpen} onClose={() => setLogoutSheetOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAccountPage({ onLogout }: { onLogout: () => void }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-4 pb-28 pt-5 text-slate-950 md:hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<PMFlowBrand compact />
|
||||
<button type="button" className="rounded-2xl border border-[#e6edf7] px-3 py-2 text-sm font-semibold text-slate-700">
|
||||
个人空间 <ChevronDown className="ml-1 inline h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<AccountProfileSummary />
|
||||
</div>
|
||||
|
||||
<section className="mt-7">
|
||||
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">账号</h2>
|
||||
<div className="space-y-3">
|
||||
<AccountListItem icon={UserRound} title="个人资料" />
|
||||
<AccountListItem icon={Bell} title="通知设置" />
|
||||
<AccountListItem icon={PanelTop} title="空间偏好" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-7">
|
||||
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">支持</h2>
|
||||
<div className="space-y-3">
|
||||
<AccountListItem icon={CircleHelp} title="帮助中心" />
|
||||
<AccountListItem icon={Settings} title="反馈与建议" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-7">
|
||||
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">账号操作</h2>
|
||||
<div className="space-y-3">
|
||||
<AccountListItem icon={Repeat2} title="切换账号" />
|
||||
<AccountListItem icon={LogOut} title="退出登录" danger onClick={onLogout} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue