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 并行开发治理
|
## 17. Codex 并行开发治理
|
||||||
|
|
||||||
- 多个 Codex 线程并行推进时,执行单位必须是 `Packet`,不得以模糊“大模块”并行开工。
|
- 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。
|
||||||
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / owner_thread / base_ref / read_scope / write_scope / forbidden_paths / shared_files / depends_on / merge_after / verification / docs_to_sync`。
|
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / module_lane / owner_thread / lane_owner / integration_owner / base_ref / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / depends_on / merge_after / verification / docs_to_sync`。
|
||||||
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
|
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
|
||||||
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
|
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
|
||||||
|
- 大模块并行时,`Shell / Integration owner` 负责共享路由、导航、Provider、共享 API base、共享文档和最终合并;模块线程只能写自己的 owned files。
|
||||||
|
- `Space` 线程只做个人空间聚合、草案箱、待确认摘要、AI 进行中摘要和跳转,不直接写 Project Memory、ChangeSet commit 或 Execution Bridge 回写。
|
||||||
|
- `Account Settings` 线程只做个人资料、偏好、通知、账户壳和设置入口,不直接改项目主链、权限核心、AI 路由或 Space 聚合。
|
||||||
|
- `Main Flow` 线程继续推进 PMFlow OS 主链,但不得顺手重做 Space 或个人中心。
|
||||||
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
|
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
|
||||||
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
|
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
|
||||||
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
|
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
|
||||||
|
|
@ -225,7 +229,11 @@ frontend/ # React 前端
|
||||||
- `backend/app/schemas/*.py` 中的跨域 schema
|
- `backend/app/schemas/*.py` 中的跨域 schema
|
||||||
- `backend/alembic/versions/*`
|
- `backend/alembic/versions/*`
|
||||||
- `frontend/src/router/index.tsx`
|
- `frontend/src/router/index.tsx`
|
||||||
|
- `frontend/src/main.tsx`
|
||||||
- `frontend/src/api/base.ts`
|
- `frontend/src/api/base.ts`
|
||||||
|
- `frontend/src/lib/api.ts`
|
||||||
|
- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`
|
||||||
|
- `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`
|
||||||
- `frontend/src/features/pmflow-os/store.tsx`
|
- `frontend/src/features/pmflow-os/store.tsx`
|
||||||
- `CLAUDE.md`
|
- `CLAUDE.md`
|
||||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||||
|
|
|
||||||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -9,7 +9,7 @@
|
||||||
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
|
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
|
||||||
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
|
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
|
||||||
- 新增前端快回路约定:`L1 微调` 默认走 `verify:login-fast` 或 focused screenshot,`L2 视觉逻辑` 默认走 `verify:login-responsive` 或 `capture:login:matrix`,出现 `context_compacted` 或超过 `8` 分钟仍未落代码时优先收口或切线程。
|
- 新增前端快回路约定:`L1 微调` 默认走 `verify:login-fast` 或 focused screenshot,`L2 视觉逻辑` 默认走 `verify:login-responsive` 或 `capture:login:matrix`,出现 `context_compacted` 或超过 `8` 分钟仍未落代码时优先收口或切线程。
|
||||||
- 新增并行开发约定:多个 Codex 线程并行推进时必须按 Packet 分派,声明 `read_scope / write_scope / forbidden_paths / shared_files / base_ref`,默认使用 `codex/<packet_id>` 独立分支和独立 worktree;共享高冲突文件只能由 integration owner 统一合并。
|
- 新增并行开发约定:多个 Codex 线程可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行仍必须按 Packet 分派,声明 `module_lane / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / base_ref`,默认使用 `codex/<packet_id>` 独立分支和独立 worktree;共享路由、导航、Provider、API base 和文档只能由 integration owner 统一合并。当前 P0 已冻结 route 归属:`/space` 归 Space,`/pmflow-os / /command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow,`/me` 归 Account Settings,`/settings` 仅为 reserved route、尚未实现。
|
||||||
- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。
|
- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。
|
||||||
|
|
||||||
## 2. 项目定位
|
## 2. 项目定位
|
||||||
|
|
@ -110,12 +110,13 @@ frontend/
|
||||||
- 当前前端重点入口优先看:
|
- 当前前端重点入口优先看:
|
||||||
- `frontend/src/api/`(统一 API 封装)
|
- `frontend/src/api/`(统一 API 封装)
|
||||||
- `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏,登录后默认主入口已切到 `/command-center`,`/space`、`/project-launch`、`/pmflow-os` 与 `/me` 是可直接打开的个人空间 / 新项目启动台 / PMFlow OS 项目上下文 / 我的账号入口,`/command-center` 也提供免登录产品预览壳,其余后台重页面按 route-level lazy 进入)
|
- `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏,登录后默认主入口已切到 `/command-center`,`/space`、`/project-launch`、`/pmflow-os` 与 `/me` 是可直接打开的个人空间 / 新项目启动台 / PMFlow OS 项目上下文 / 我的账号入口,`/command-center` 也提供免登录产品预览壳,其余后台重页面按 route-level lazy 进入)
|
||||||
- `frontend/src/features/personal-space/`(个人空间首页;本地 mock data 驱动继续推进、待我确认、AI 正在推进、暂存想法、草案箱和新建项目入口)
|
- `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary,承载继续推进、待确认、AI 正在推进、草案箱和最近项目入口)
|
||||||
- `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机)
|
- `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机)
|
||||||
- `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 调后端,显式 `?demo=1` 才启用本地演示状态机)
|
- `frontend/src/features/account-settings/`(`/me` 个人中心与设置;通过 `frontend/src/api/accountSettings.ts` 管理个人资料、默认语言、AI 回答深度、证据展示偏好和通知偏好,移动端使用分组列表与 bottom sheet,不进入 auth core)
|
||||||
- `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;C1 不实现 commit)
|
- `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=<id>` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 与 ChangeSet `commit` 调后端,显式 `?demo=1` 才启用本地演示状态机)
|
||||||
|
- `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1/C2 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer` 与 ChangeSet `commit`;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed)
|
||||||
- `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed)
|
- `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed)
|
||||||
- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,commit 仍未实现)
|
- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,Phase C2 backend/frontend commit 已实现)
|
||||||
- `frontend/src/pages/inbox/`(消息台)
|
- `frontend/src/pages/inbox/`(消息台)
|
||||||
- `frontend/src/pages/ai-steward/`(AI 管家)
|
- `frontend/src/pages/ai-steward/`(AI 管家)
|
||||||
- `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示)
|
- `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示)
|
||||||
|
|
@ -131,8 +132,9 @@ frontend/
|
||||||
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
|
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
|
||||||
- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;AI Steward / AI Config 页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。
|
- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;AI Steward / AI Config 页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。
|
||||||
- `frontend/src/features/project-launch/` 是新项目启动台独立产品域:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,`mockData.ts` 只在显式 demo 模式下使用;默认路径调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=<id>`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。
|
- `frontend/src/features/project-launch/` 是新项目启动台独立产品域:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,`mockData.ts` 只在显式 demo 模式下使用;默认路径调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=<id>`。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。
|
||||||
- `frontend/src/features/personal-space/` 是空间层入口:`mockData.ts` 固化本地项目、待确认、AI 推进、暂存想法与草案箱数据,`PersonalSpacePage.tsx` 承接桌面空间首页与独立移动布局,`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。
|
- `frontend/src/features/personal-space/` 是空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 并生成继续推进、待确认、AI 正在推进、草案箱、最近项目摘要;点击项目/确认/草案只跳回 `/pmflow-os?projectId=<id>` 主流程处理,不在 Space 写 Project Memory 或 execution event。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。
|
||||||
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` API,成功后 refetch truth-state;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 不会进入 Project Memory,commit 留给 Phase C2。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。
|
- `frontend/src/features/account-settings/` 是 Account Settings 独立产品域:`AccountSettingsPanel.tsx` 承接 `/me` 桌面设置页、移动端分组列表与 bottom sheet,`frontend/src/api/accountSettings.ts` 定义个人资料、默认语言、AI 回答深度、证据展示偏好和四类通知偏好合同,当前首版使用浏览器本地持久化作为前端合同落点,不改 OAuth/JWT/auth core;`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 仅做薄入口并复用既有退出登录 bottom sheet。
|
||||||
|
- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=<id>` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer` 与 ChangeSet `commit` API,成功后 refetch truth-state;显式 `?demo=1` 才启用 `command_idle -> ai_extracting -> extraction_ready -> review_open -> writeback_success` 本地演示状态机,以及本地 `promoteAcceptedToGraph` 等演示动作。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,真实模式下 accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件,用来承接项目指挥台、结构化提取、待确认审查、项目记忆写入结果、项目内面包屑、项目切换器、账号退出登录交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组契约化提示提醒组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。
|
||||||
- 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。
|
- 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。
|
||||||
|
|
||||||
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,53 @@ P0 公共壳冻结已完成,下一批可以进入 `Batch P1` 三模块并行
|
||||||
|
|
||||||
## 8. 进度台账
|
## 8. 进度台账
|
||||||
|
|
||||||
|
### 2026-04-26 / big-module-integration-p1 / done
|
||||||
|
|
||||||
|
完成内容:
|
||||||
|
|
||||||
|
- 作为 `Shell / Integration owner` 收口 Batch P1 三条 lane:Main Flow D.2、Space V1 Workbench Hardening、Account Settings V1。
|
||||||
|
- 已确认当前分支为 `codex/main-flow-phase-d2-execution-handoff-contract`,Main Flow D.2 已落提交 `f95bd15 feat(pmflow-os): add execution handoff contract`,本轮不重写 D.2,只验证其与 Space / Account dirty worktree 的组合。
|
||||||
|
- 已检查 tracked diff 与 untracked files,当前脏文件均落在本次集成范围:`AGENTS.md / CLAUDE.md / TASK_BRIEF_TEMPLATE.md / ARCHITECTURE_INVENTORY.md / personal-space / account-settings / accountSettings.ts / space.ts / AccountPage.tsx`。
|
||||||
|
- Space 改动符合 P0 边界:`frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS `truth-state / execution-summary`,待确认项只跳转 `/change-review`,不直接写 Project Memory、ChangeSet 状态或 Execution Bridge event。
|
||||||
|
- Account Settings 改动符合 P0 边界:`/me` 由 `AccountSettingsPanel` 承接个人资料、AI 偏好和通知偏好,首版只做浏览器本机持久化,不改 OAuth/JWT/auth core、router、MobileBottomNav、PMFlow OS store 或项目主链。
|
||||||
|
- P0 文档残留已收口:`AGENTS.md / CLAUDE.md / TASK_BRIEF_TEMPLATE.md` 的大模块 lane、integration owner、共享文件锁与 Packet 模板规则可纳入 integration commit;`ARCHITECTURE_INVENTORY.md` 修正旧的 Space 只能消费 mock data / 禁止接后端描述,改为当前只读聚合合同。
|
||||||
|
- `CODE_REFERENCE_INDEX.md` 已确认包含 Space API、Account Settings API、PMFlow OS D.2 execution handoff API 索引;`CLAUDE.md` 已确认不再保留旧的“commit 未实现”描述。
|
||||||
|
|
||||||
|
涉及文件:
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `CLAUDE.md`
|
||||||
|
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
|
||||||
|
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||||
|
- `docs/architecture/TASK_BRIEF_TEMPLATE.md`
|
||||||
|
- `frontend/src/api/space.ts`
|
||||||
|
- `frontend/src/api/accountSettings.ts`
|
||||||
|
- `frontend/src/features/personal-space/PersonalSpacePage.tsx`
|
||||||
|
- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`
|
||||||
|
- `frontend/src/features/personal-space/components/SpaceChrome.tsx`
|
||||||
|
- `frontend/src/features/account-settings/AccountSettingsPanel.tsx`
|
||||||
|
- `frontend/src/features/account-settings/accountSettings.test.ts`
|
||||||
|
- `frontend/src/features/pmflow-os/pages/AccountPage.tsx`
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
- `cd frontend && npm run test -- src/features/personal-space/PersonalSpacePage.test.tsx` 通过:`1 passed`。
|
||||||
|
- `cd frontend && npm run test -- src/features/account-settings/accountSettings.test.ts` 通过:`3 passed`。
|
||||||
|
- `cd frontend && npm run test -- src/features/pmflow-os` 通过:`4 passed`。
|
||||||
|
- `cd frontend && npm run typecheck` 通过。
|
||||||
|
- `cd frontend && npm run build` 通过;Vite 仍输出既有 manual chunk circular warning(`ai-steward / sstc / charts / governance`),未阻断构建。
|
||||||
|
- `PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q` 通过:`9 passed`;仍有既有 `backend/app/schemas/recipe.py` Pydantic V2 class-based config deprecation warning。
|
||||||
|
|
||||||
|
未完成或风险:
|
||||||
|
|
||||||
|
- 本 integration packet 未新增或修改后端运行时代码、Schema 或 migration;本轮未执行热部署/重启。当前验证发生在本地开发工作区,未连接实际部署服务;若要让 Main Flow D.2 的后端提交在部署环境生效,需要在对应部署机执行 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart` 或等价发布流程。
|
||||||
|
- Space 的 `savedIdeas` 后端只读聚合合同仍未实现;当前 UI 明确展示 empty/warning,不使用 mock 数据冒充真实内容。
|
||||||
|
- Account Settings V1 仍是前端合同 + 本机持久化;跨设备同步需要后续独立新增 `account_settings_api` 与持久化表。
|
||||||
|
|
||||||
|
下一步建议:
|
||||||
|
|
||||||
|
- 进入 D.3:补 Dev Sync 写回闭环,把 Codex / Local Bridge / GitHub agent 的 branch、commit、PR、test progress event 回写到 `execution-summary / truth-state`。
|
||||||
|
|
||||||
### 2026-04-26 / Space V1 Workbench Hardening / implemented
|
### 2026-04-26 / Space V1 Workbench Hardening / implemented
|
||||||
|
|
||||||
完成内容:
|
完成内容:
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@
|
||||||
- AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。
|
- AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。
|
||||||
- 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。
|
- 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。
|
||||||
- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。
|
- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。
|
||||||
- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页与新项目启动台同样是独立产品域,只能分别消费 `frontend/src/features/personal-space/` 的本地 mock data 与 `frontend/src/features/project-launch/` 的本地状态机,禁止在首版中接后端、真实 AI 调用、真实项目创建 API、团队空间强切换或完整企业组织管理流程。
|
- PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页、新项目启动台和 Account Settings 同样是独立产品域:Space 只能通过 `frontend/src/api/space.ts` 做项目摘要、PMFlow OS `truth-state / execution-summary` 的只读聚合并跳回主流程;Project Launch 默认走真实 draft/confirm 创建链,只有显式 demo 参数才使用本地状态机;Account Settings 只管理个人资料、偏好和通知,不改 OAuth/JWT/auth core、PMFlow OS truth store、Project Memory、ChangeSet 或 Execution Bridge 写回。
|
||||||
|
|
||||||
## 6. 工具与运行库存
|
## 6. 工具与运行库存
|
||||||
- `backend/scripts/repair_requirement_plan_history.py`
|
- `backend/scripts/repair_requirement_plan_history.py`
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@
|
||||||
|
|
||||||
本模板用于 PMFlow Codex 单线程或并行 Packet 分派。并行任务必须填写 `Read Scope / Write Scope / Forbidden Paths`;没有 `write_scope` 的任务不允许并行执行。
|
本模板用于 PMFlow Codex 单线程或并行 Packet 分派。并行任务必须填写 `Read Scope / Write Scope / Forbidden Paths`;没有 `write_scope` 的任务不允许并行执行。
|
||||||
|
|
||||||
|
大模块并行时,`Module Lane` 表示长期产品表面归属,例如 `Shell / Integration`、`Main Flow / PMFlow OS`、`Space`、`Account Settings`。`Packet` 仍是实际执行单位;不能只填写模块名后直接开工。
|
||||||
|
|
||||||
## Meta
|
## Meta
|
||||||
|
|
||||||
- Packet ID: `{{PACKET_ID}}`
|
- Packet ID: `{{PACKET_ID}}`
|
||||||
|
- Module Lane: `Shell / Integration | Main Flow / PMFlow OS | Space | Account Settings | Other`
|
||||||
- Phase: `{{PHASE}}`
|
- Phase: `{{PHASE}}`
|
||||||
- Owner: `{{OWNER}}`
|
- Owner: `{{OWNER}}`
|
||||||
|
- Lane Owner: `{{LANE_OWNER}}`
|
||||||
|
- Integration Owner: `{{INTEGRATION_OWNER}}`
|
||||||
- Generated At: `{{GENERATED_AT}}`
|
- Generated At: `{{GENERATED_AT}}`
|
||||||
- Base Ref: `{{BASE_REF}}`
|
- Base Ref: `{{BASE_REF}}`
|
||||||
- Head Ref: `{{HEAD_REF}}`
|
- Head Ref: `{{HEAD_REF}}`
|
||||||
|
|
@ -28,6 +33,23 @@
|
||||||
|
|
||||||
{{TARGET_OBJECT_OR_CONTRACT}}
|
{{TARGET_OBJECT_OR_CONTRACT}}
|
||||||
|
|
||||||
|
## Module Boundary
|
||||||
|
|
||||||
|
- Lane Responsibility:
|
||||||
|
{{LANE_RESPONSIBILITY}}
|
||||||
|
|
||||||
|
- Cross-lane Inputs:
|
||||||
|
{{CROSS_LANE_INPUTS}}
|
||||||
|
|
||||||
|
- Cross-lane Outputs:
|
||||||
|
{{CROSS_LANE_OUTPUTS}}
|
||||||
|
|
||||||
|
- Shared Shell / Route / Navigation Impact:
|
||||||
|
{{SHELL_ROUTE_NAV_IMPACT}}
|
||||||
|
|
||||||
|
- If shared files are needed, owner approval / dependency:
|
||||||
|
{{SHARED_OWNER_APPROVAL_OR_DEPENDENCY}}
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- Read Scope:
|
- Read Scope:
|
||||||
|
|
@ -76,6 +98,7 @@
|
||||||
## Integration Notes
|
## Integration Notes
|
||||||
|
|
||||||
- Integration Owner: `{{INTEGRATION_OWNER}}`
|
- Integration Owner: `{{INTEGRATION_OWNER}}`
|
||||||
|
- Module Lane Merge Group: `{{MODULE_LANE_MERGE_GROUP}}`
|
||||||
- Merge Order: `{{MERGE_ORDER}}`
|
- Merge Order: `{{MERGE_ORDER}}`
|
||||||
- Expected Commit(s): `{{EXPECTED_COMMITS}}`
|
- Expected Commit(s): `{{EXPECTED_COMMITS}}`
|
||||||
- Out-of-scope Findings:
|
- Out-of-scope Findings:
|
||||||
|
|
@ -86,6 +109,9 @@
|
||||||
- [ ] `base_ref` recorded
|
- [ ] `base_ref` recorded
|
||||||
- [ ] `read_scope` and `write_scope` declared
|
- [ ] `read_scope` and `write_scope` declared
|
||||||
- [ ] `forbidden_paths` checked
|
- [ ] `forbidden_paths` checked
|
||||||
|
- [ ] Module lane declared
|
||||||
|
- [ ] Cross-lane inputs/outputs declared
|
||||||
|
- [ ] Shared shell/router/nav impact declared
|
||||||
- [ ] Shared files reserved or assigned to integration owner
|
- [ ] Shared files reserved or assigned to integration owner
|
||||||
- [ ] Code completed
|
- [ ] Code completed
|
||||||
- [ ] Contracts synchronized
|
- [ ] Contracts synchronized
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const sidebarMain = [
|
const sidebarMain = [
|
||||||
{ label: "空间首页", icon: Home, badge: undefined },
|
{ label: "空间首页", icon: Home, metricKey: undefined },
|
||||||
{ label: "最近项目", icon: ClockIcon, badge: undefined },
|
{ label: "最近项目", icon: ClockIcon, metricKey: "recentProjects" },
|
||||||
{ label: "暂存想法", icon: MessageCircle, badge: 8 },
|
{ label: "暂存想法", icon: MessageCircle, metricKey: "savedIdeas" },
|
||||||
{ label: "草案箱", icon: FileBox, badge: 5 },
|
{ label: "草案箱", icon: FileBox, metricKey: "drafts" },
|
||||||
{ label: "待我确认", icon: CheckCircle2, badge: 12 },
|
{ label: "待我确认", icon: CheckCircle2, metricKey: "pendingConfirmations" },
|
||||||
{ label: "AI 正在推进", icon: Sparkles, badge: 6 },
|
{ label: "AI 正在推进", icon: Sparkles, metricKey: "aiProgress" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidebarTools = [
|
const sidebarTools = [
|
||||||
|
|
@ -45,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 (
|
return (
|
||||||
<aside className="flex w-[244px] shrink-0 flex-col border-r border-[#e6edf7] bg-[#fbfcff] px-5 py-5">
|
<aside className="flex w-[244px] shrink-0 flex-col border-r border-[#e6edf7] bg-[#fbfcff] px-5 py-5">
|
||||||
<PMFlowBrandHeader />
|
<PMFlowBrandHeader />
|
||||||
<nav className="mt-8 space-y-2">
|
<nav className="mt-8 space-y-2">
|
||||||
{sidebarMain.map((item) => (
|
{sidebarMain.map((item) => {
|
||||||
|
const badge = item.metricKey ? metrics?.[item.metricKey] : undefined;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -63,9 +71,10 @@ export function SpaceSidebar({ activeLabel = "空间首页" }: { activeLabel?: s
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="my-7 h-px bg-[#e6edf7]" />
|
<div className="my-7 h-px bg-[#e6edf7]" />
|
||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,14 @@
|
||||||
import {
|
|
||||||
Bell,
|
|
||||||
ChevronDown,
|
|
||||||
CircleHelp,
|
|
||||||
LogOut,
|
|
||||||
PanelTop,
|
|
||||||
Repeat2,
|
|
||||||
Settings,
|
|
||||||
ShieldCheck,
|
|
||||||
UserRound,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { AccountSettingsPanel } from "@/features/account-settings/AccountSettingsPanel";
|
||||||
import {
|
import { MobileLogoutSheet } from "@/features/pmflow-os/components/AccountControls";
|
||||||
AccountListItem,
|
|
||||||
AccountProfileSummary,
|
|
||||||
MobileLogoutSheet,
|
|
||||||
} from "@/features/pmflow-os/components/AccountControls";
|
|
||||||
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
|
|
||||||
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const [logoutSheetOpen, setLogoutSheetOpen] = useState(false);
|
const [logoutSheetOpen, setLogoutSheetOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden min-h-full bg-white md:block">
|
<AccountSettingsPanel onLogout={() => setLogoutSheetOpen(true)} />
|
||||||
<div className="mx-auto max-w-[980px] px-8 py-8">
|
|
||||||
<div className="mb-7 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-[-0.04em] text-slate-950">账号与空间</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-500">管理当前身份动作。退出登录不会改变项目层级,也不会中断 AI 正在推进的工作。</p>
|
|
||||||
</div>
|
|
||||||
<Link to="/space" className="rounded-xl border border-[#dfe7f3] px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-[#2463EB] hover:text-[#2463EB]">
|
|
||||||
返回个人空间
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[360px_minmax(0,1fr)]">
|
|
||||||
<AccountProfileSummary />
|
|
||||||
<section className="rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#eef4ff] text-[#2463EB]">
|
|
||||||
<ShieldCheck className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">退出规则</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">AI 生成内容保持待确认状态,只有你确认后才会写入项目记忆。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 grid gap-3 text-sm text-slate-600">
|
|
||||||
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">AI 推进不会中断,进度会保留在个人空间。</div>
|
|
||||||
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">草案和待确认事项不会被当作已确认事实。</div>
|
|
||||||
<div className="rounded-2xl border border-[#e6edf7] bg-[#f8fbff] px-4 py-3">已确认的项目记忆不受退出登录影响。</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
|
||||||
<section className="space-y-3 rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
|
||||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">账号</h2>
|
|
||||||
<AccountListItem icon={UserRound} title="个人资料" detail="姓名、角色与默认头像" />
|
|
||||||
<AccountListItem icon={Bell} title="通知设置" detail="待确认、AI 推进和项目动态提醒" />
|
|
||||||
<AccountListItem icon={PanelTop} title="空间偏好" detail="默认个人空间与页面显示偏好" />
|
|
||||||
</section>
|
|
||||||
<section className="space-y-3 rounded-[24px] border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
|
|
||||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950">支持与账号操作</h2>
|
|
||||||
<AccountListItem icon={CircleHelp} title="帮助与反馈" detail="查看帮助文档或提交建议" />
|
|
||||||
<AccountListItem icon={Repeat2} title="切换账号" detail="保留当前项目上下文,切换另一个身份" />
|
|
||||||
<AccountListItem icon={LogOut} title="退出登录" detail="退出当前身份,保留空间内草案与待确认事项" danger onClick={() => setLogoutSheetOpen(true)} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MobileAccountPage onLogout={() => setLogoutSheetOpen(true)} />
|
|
||||||
<MobileLogoutSheet open={logoutSheetOpen} onClose={() => setLogoutSheetOpen(false)} />
|
<MobileLogoutSheet open={logoutSheetOpen} onClose={() => setLogoutSheetOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileAccountPage({ onLogout }: { onLogout: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white px-4 pb-28 pt-5 text-slate-950 md:hidden">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<PMFlowBrand compact />
|
|
||||||
<button type="button" className="rounded-2xl border border-[#e6edf7] px-3 py-2 text-sm font-semibold text-slate-700">
|
|
||||||
个人空间 <ChevronDown className="ml-1 inline h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<AccountProfileSummary />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="mt-7">
|
|
||||||
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">账号</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<AccountListItem icon={UserRound} title="个人资料" />
|
|
||||||
<AccountListItem icon={Bell} title="通知设置" />
|
|
||||||
<AccountListItem icon={PanelTop} title="空间偏好" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-7">
|
|
||||||
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">支持</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<AccountListItem icon={CircleHelp} title="帮助中心" />
|
|
||||||
<AccountListItem icon={Settings} title="反馈与建议" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-7">
|
|
||||||
<h2 className="mb-3 text-lg font-semibold tracking-[-0.04em] text-slate-950">账号操作</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<AccountListItem icon={Repeat2} title="切换账号" />
|
|
||||||
<AccountListItem icon={LogOut} title="退出登录" danger onClick={onLogout} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<MobileBottomNav />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue