From 7793e9238dc40c0b0f47a2154edbfd34efe72cb6 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 20 Mar 2026 15:39:33 +0800 Subject: [PATCH] refactor: unify settings drill-in navigation --- docs/plans/2026-03-20.md | 137 ++++++++++ lib/app/app_controller.dart | 176 +++++++++++- lib/app/app_shell.dart | 70 +---- lib/app/workspace_navigation.dart | 79 ++++++ lib/app/workspace_page_registry.dart | 176 ++++++++++++ lib/features/ai_gateway/ai_gateway_page.dart | 181 +++++++++++-- lib/features/mobile/mobile_shell.dart | 64 +---- lib/features/modules/modules_page.dart | 69 +++-- lib/features/secrets/secrets_page.dart | 73 +++-- lib/features/settings/settings_page.dart | 267 ++++++++++++++----- lib/features/skills/skills_page.dart | 5 + lib/features/tasks/tasks_page.dart | 5 + lib/models/app_models.dart | 79 ++++++ lib/widgets/desktop_workspace_scaffold.dart | 8 + test/features/ai_gateway_page_test.dart | 179 ++++++++----- test/features/modules_page_test.dart | 14 +- test/features/settings_page_test.dart | 35 +++ 17 files changed, 1288 insertions(+), 329 deletions(-) create mode 100644 docs/plans/2026-03-20.md create mode 100644 lib/app/workspace_navigation.dart create mode 100644 lib/app/workspace_page_registry.dart diff --git a/docs/plans/2026-03-20.md b/docs/plans/2026-03-20.md new file mode 100644 index 00000000..8a0e2495 --- /dev/null +++ b/docs/plans/2026-03-20.md @@ -0,0 +1,137 @@ +# XWorkmate 菜单层级、状态页与面包屑优化实施计划 + +## 1. 背景与目标 + +本轮改造的首要约束是:保持截图对应的 `Assistant` 首页 UI 设计、侧栏分配和主壳布局不变,不新增新的一级路由,不改变首页主工作区的视觉结构。 + +在这个前提下,导航和页面职责收敛为稳定的三级模型: + +- 1 级:保持现有 `WorkspaceDestination` 一级入口与侧栏分配 +- 2 级:配置相关页面只负责快速查看状态和少量高频操作 +- 3 级:详细设置参数统一进入 `Settings` 体系下的独立 detail 视图 + +本轮默认决策: + +- `Assistant` 首页是唯一 Home +- `主页` 面包屑统一调用 `navigateHome()` +- 配置型页面采用 `二级状态页 -> 三级参数页` +- 二级页可以快速查看状态,但不再承载完整高级表单 +- 三级参数页挂在 `Settings` 体系内,不新增新的一级路由 +- 本轮不做 runtime 大拆分,但不得新增新的 `runtime -> app` 反向依赖 + +## 2. 导航层级与页面职责 + +### 一级入口保持不变 + +现有一级入口继续保留: + +- `Assistant` +- `Tasks` +- `Skills` +- `Nodes` +- `Agents` +- `MCP Hub` +- `ClawHub` +- `Secrets` +- `AI Gateway` +- `Settings` +- `Account` + +### 二级状态页 + +本轮重点覆盖四类配置型页面: + +- `Modules` +- `AI Gateway` +- `Secrets` +- `Settings` + +二级状态页统一遵守以下边界: + +- 展示连接状态、健康状态、当前模式、最近结果和关键摘要 +- 保留少量高频动作,例如刷新、重试连接、进入编辑设置 +- 不再承载完整高级表单 +- 不再出现重复的“打开全页”式独立配置入口 + +### 三级参数页 + +详细参数通过 `Settings` 的 detail 视图承载,第一批 detail 包含: + +- `Gateway Connection` +- `AI Gateway Integration` +- `Vault Provider` +- `Ollama Provider` +- `External Agents` +- `Advanced Diagnostics` + +`Settings` 是详细参数的唯一权威入口。`Modules / AI Gateway / Secrets` 只负责状态和进入编辑。 + +## 3. 架构收口与导航注册表 + +### 控制器状态 + +`AppController` 承担统一导航状态入口,新增以下能力: + +- 配置型页面的 tab 状态 +- `Settings` drill-in 状态 +- `openSettings({tab, detail})` +- `closeSettingsDetail()` +- `navigateHome()` 返回 `Assistant` 首页并清空 detail 状态 + +### 共享导航注册表 + +`AppShell` 与 `MobileShell` 共用同一套页面注册元数据,统一描述: + +- 一级入口 +- Desktop / Mobile 共用的页面 builder +- `Settings` detail 的初始 tab / detail 注入 +- `Modules / Secrets / AI Gateway` 的初始 tab 注入 + +本轮优先收口 `AppShell / MobileShell` 的 destination -> page 映射重复,不在 feature 页面里继续扩散新的页面分发逻辑。 + +### 面包屑规则 + +非首页页面统一支持通过面包屑回到 `Assistant` 首页: + +- 一级工作页:`主页 / 当前页` +- 二级状态页:`主页 / 一级入口 / 当前状态页` +- 三级参数页:`主页 / 一级入口 / 二级状态页 / 当前参数页` + +`Settings` detail 页面允许保留来源上下文。例如: + +- `主页 / 模块 / 网关 / Gateway 连接参数` +- `主页 / 密钥 / Vault / Vault 提供方参数` +- `主页 / AI Gateway / 模型 / AI Gateway 集成参数` + +## 4. 测试与验收标准 + +### 功能验收 + +- 首页截图对应 UI、侧栏、主工作区、输入区视觉不变 +- `Modules / AI Gateway / Secrets` 只展示状态摘要和少量高频动作 +- 详细参数通过 `Settings` detail 页面编辑 +- 任意非首页页面可通过 `主页` 面包屑回到 `Assistant` 首页主路由 +- `Settings` detail 页面可通过来源面包屑返回对应二级状态页 + +### 架构验收 + +- `AppShell` 与 `MobileShell` 共用同一套页面注册元数据 +- 本次导航改造不新增新的 `runtime -> app` 反向依赖 +- 导航规则和 breadcrumb 生成逻辑不再散落在各 feature 页面中重复实现 + +### 回归测试 + +本轮至少覆盖以下回归: + +- `settings_page` +- `modules_page` +- `ai_gateway_page` +- `assistant_page` +- `sidebar_navigation` + +补充的行为验证: + +- 二级页点击 `编辑设置` 进入对应三级参数页 +- 三级参数页 breadcrumb 可返回来源状态页 +- 任意非首页页点击 `主页` 回到 `Assistant` 首页主路由 +- 二级页不再承载完整高级表单 diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 121a326b..4e7be25c 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -128,6 +128,12 @@ class AppController extends ChangeNotifier { WorkspaceDestination _destination = WorkspaceDestination.assistant; ThemeMode _themeMode = ThemeMode.light; AppSidebarState _sidebarState = AppSidebarState.expanded; + ModulesTab _modulesTab = ModulesTab.gateway; + SecretsTab _secretsTab = SecretsTab.vault; + AiGatewayTab _aiGatewayTab = AiGatewayTab.models; + SettingsTab _settingsTab = SettingsTab.general; + SettingsDetailPage? _settingsDetail; + SettingsNavigationContext? _settingsNavigationContext; DetailPanelData? _detailPanel; bool _initializing = true; String? _bootstrapError; @@ -137,6 +143,13 @@ class AppController extends ChangeNotifier { WorkspaceDestination get destination => _destination; ThemeMode get themeMode => _themeMode; AppSidebarState get sidebarState => _sidebarState; + ModulesTab get modulesTab => _modulesTab; + SecretsTab get secretsTab => _secretsTab; + AiGatewayTab get aiGatewayTab => _aiGatewayTab; + SettingsTab get settingsTab => _settingsTab; + SettingsDetailPage? get settingsDetail => _settingsDetail; + SettingsNavigationContext? get settingsNavigationContext => + _settingsNavigationContext; DetailPanelData? get detailPanel => _detailPanel; bool get initializing => _initializing; String? get bootstrapError => _bootstrapError; @@ -670,10 +683,7 @@ class AppController extends ChangeNotifier { } byKey.putIfAbsent( normalizedSessionKey, - () => _assistantSessionSummaryFor( - normalizedSessionKey, - record: record, - ), + () => _assistantSessionSummaryFor(normalizedSessionKey, record: record), ); } @@ -701,10 +711,25 @@ class AppController extends ChangeNotifier { } void navigateTo(WorkspaceDestination destination) { - if (_destination == destination) { + final nextModulesTab = switch (destination) { + WorkspaceDestination.nodes => ModulesTab.nodes, + WorkspaceDestination.agents => ModulesTab.agents, + _ => _modulesTab, + }; + final shouldClearSettingsDrillIn = + _settingsDetail != null || _settingsNavigationContext != null; + final changed = + _destination != destination || + _detailPanel != null || + shouldClearSettingsDrillIn || + nextModulesTab != _modulesTab; + if (!changed) { return; } _destination = destination; + _modulesTab = nextModulesTab; + _settingsDetail = null; + _settingsNavigationContext = null; _detailPanel = null; notifyListeners(); } @@ -716,9 +741,13 @@ class AppController extends ChangeNotifier { : 'main'; final destinationChanged = _destination != WorkspaceDestination.assistant; final detailChanged = _detailPanel != null; + final settingsDrillInChanged = + _settingsDetail != null || _settingsNavigationContext != null; _destination = WorkspaceDestination.assistant; + _settingsDetail = null; + _settingsNavigationContext = null; _detailPanel = null; - if (destinationChanged || detailChanged) { + if (destinationChanged || detailChanged || settingsDrillInChanged) { notifyListeners(); } if (_sessionsController.currentSessionKey != mainSessionKey) { @@ -726,6 +755,135 @@ class AppController extends ChangeNotifier { } } + void openModules({ModulesTab tab = ModulesTab.gateway}) { + final destination = tab == ModulesTab.agents + ? WorkspaceDestination.agents + : WorkspaceDestination.nodes; + final changed = + _destination != destination || + _modulesTab != tab || + _detailPanel != null || + _settingsDetail != null || + _settingsNavigationContext != null; + if (!changed) { + return; + } + _destination = destination; + _modulesTab = tab; + _detailPanel = null; + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void setModulesTab(ModulesTab tab) { + if (_modulesTab == tab) { + return; + } + _modulesTab = tab; + notifyListeners(); + } + + void openSecrets({SecretsTab tab = SecretsTab.vault}) { + final changed = + _destination != WorkspaceDestination.secrets || + _secretsTab != tab || + _detailPanel != null || + _settingsDetail != null || + _settingsNavigationContext != null; + if (!changed) { + return; + } + _destination = WorkspaceDestination.secrets; + _secretsTab = tab; + _detailPanel = null; + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void setSecretsTab(SecretsTab tab) { + if (_secretsTab == tab) { + return; + } + _secretsTab = tab; + notifyListeners(); + } + + void openAiGateway({AiGatewayTab tab = AiGatewayTab.models}) { + final changed = + _destination != WorkspaceDestination.aiGateway || + _aiGatewayTab != tab || + _detailPanel != null || + _settingsDetail != null || + _settingsNavigationContext != null; + if (!changed) { + return; + } + _destination = WorkspaceDestination.aiGateway; + _aiGatewayTab = tab; + _detailPanel = null; + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void setAiGatewayTab(AiGatewayTab tab) { + if (_aiGatewayTab == tab) { + return; + } + _aiGatewayTab = tab; + notifyListeners(); + } + + void openSettings({ + SettingsTab tab = SettingsTab.general, + SettingsDetailPage? detail, + SettingsNavigationContext? navigationContext, + }) { + final resolvedTab = detail?.tab ?? tab; + final changed = + _destination != WorkspaceDestination.settings || + _settingsTab != resolvedTab || + _settingsDetail != detail || + _settingsNavigationContext != navigationContext || + _detailPanel != null; + if (!changed) { + return; + } + _destination = WorkspaceDestination.settings; + _settingsTab = resolvedTab; + _settingsDetail = detail; + _settingsNavigationContext = navigationContext; + _detailPanel = null; + notifyListeners(); + } + + void setSettingsTab(SettingsTab tab, {bool clearDetail = true}) { + final changed = + _settingsTab != tab || + (clearDetail && + (_settingsDetail != null || _settingsNavigationContext != null)); + if (!changed) { + return; + } + _settingsTab = tab; + if (clearDetail) { + _settingsDetail = null; + _settingsNavigationContext = null; + } + notifyListeners(); + } + + void closeSettingsDetail() { + if (_settingsDetail == null && _settingsNavigationContext == null) { + return; + } + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + void cycleSidebarState() { _sidebarState = switch (_sidebarState) { AppSidebarState.expanded => AppSidebarState.collapsed, @@ -1219,9 +1377,11 @@ class AppController extends ChangeNotifier { sessionKey, title: title.trim(), executionTarget: - executionTarget ?? assistantExecutionTargetForSession(currentSessionKey), + executionTarget ?? + assistantExecutionTargetForSession(currentSessionKey), messageViewMode: - messageViewMode ?? assistantMessageViewModeForSession(currentSessionKey), + messageViewMode ?? + assistantMessageViewModeForSession(currentSessionKey), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); _notifyIfActive(); diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index ee965a9f..d1d82876 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -1,24 +1,15 @@ import 'package:flutter/material.dart'; import '../features/account/account_page.dart'; -import '../features/ai_gateway/ai_gateway_page.dart'; -import '../features/assistant/assistant_page.dart'; -import '../features/claw_hub/claw_hub_page.dart'; -import '../features/mcp_server/mcp_server_page.dart'; import '../features/mobile/mobile_shell.dart'; -import '../features/modules/modules_page.dart'; -import '../features/secrets/secrets_page.dart'; -import '../features/settings/settings_page.dart'; -import '../features/skills/skills_page.dart'; -import '../features/tasks/tasks_page.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../theme/app_palette.dart'; -import '../widgets/assistant_focus_panel.dart'; import '../widgets/detail_drawer.dart'; import '../widgets/pane_resize_handle.dart'; import '../widgets/sidebar_navigation.dart'; import 'app_controller.dart'; +import 'workspace_page_registry.dart'; class AppShell extends StatefulWidget { const AppShell({super.key, required this.controller}); @@ -399,59 +390,12 @@ class _AppShellState extends State { WorkspaceDestination destination, ValueChanged onOpenDetail, ) { - return switch (destination) { - WorkspaceDestination.assistant => AssistantPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - navigationPanelBuilder: - widget.controller.sidebarState == AppSidebarState.hidden - ? null - : (_) => AssistantFocusPanel(controller: widget.controller), - showStandaloneTaskRail: false, - unifiedPaneStartsCollapsed: - widget.controller.sidebarState == AppSidebarState.collapsed, - ), - WorkspaceDestination.tasks => TasksPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - ), - WorkspaceDestination.skills => SkillsPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - ), - WorkspaceDestination.nodes => ModulesPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - initialTab: ModulesTab.nodes, - ), - WorkspaceDestination.agents => ModulesPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - initialTab: ModulesTab.agents, - ), - WorkspaceDestination.mcpServer => McpServerPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - ), - WorkspaceDestination.clawHub => ClawHubPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - ), - WorkspaceDestination.secrets => SecretsPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - ), - WorkspaceDestination.aiGateway => AiGatewayPage( - controller: widget.controller, - onOpenDetail: onOpenDetail, - ), - WorkspaceDestination.settings => SettingsPage( - controller: widget.controller, - ), - WorkspaceDestination.account => AccountPage( - controller: widget.controller, - ), - }; + return buildWorkspacePage( + destination: destination, + controller: widget.controller, + onOpenDetail: onOpenDetail, + surface: WorkspacePageSurface.desktop, + ); } } diff --git a/lib/app/workspace_navigation.dart b/lib/app/workspace_navigation.dart new file mode 100644 index 00000000..6732099f --- /dev/null +++ b/lib/app/workspace_navigation.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../widgets/top_bar.dart'; +import 'app_controller.dart'; + +List buildWorkspaceBreadcrumbs({ + required AppController controller, + required String rootLabel, + String? sectionLabel, + String? detailLabel, + VoidCallback? onRootTap, +}) { + final items = [ + AppBreadcrumbItem( + label: appText('主页', 'Home'), + icon: Icons.home_rounded, + onTap: controller.navigateHome, + ), + AppBreadcrumbItem(label: rootLabel, onTap: onRootTap), + ]; + if (sectionLabel != null && sectionLabel.trim().isNotEmpty) { + items.add(AppBreadcrumbItem(label: sectionLabel)); + } + if (detailLabel != null && detailLabel.trim().isNotEmpty) { + items.add(AppBreadcrumbItem(label: detailLabel)); + } + return items; +} + +List buildSettingsBreadcrumbs( + AppController controller, { + required SettingsTab tab, + SettingsDetailPage? detail, + SettingsNavigationContext? navigationContext, +}) { + if (detail == null) { + return buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: appText('设置', 'Settings'), + sectionLabel: tab.label, + ); + } + + return buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: navigationContext?.rootLabel ?? appText('设置', 'Settings'), + sectionLabel: navigationContext?.sectionLabel ?? tab.label, + detailLabel: detail.label, + onRootTap: navigationContext == null + ? () => controller.openSettings(tab: tab) + : () => openSettingsNavigationContext(controller, navigationContext), + ); +} + +void openSettingsNavigationContext( + AppController controller, + SettingsNavigationContext context, +) { + if (context.modulesTab != null) { + controller.openModules(tab: context.modulesTab!); + return; + } + if (context.secretsTab != null) { + controller.openSecrets(tab: context.secretsTab!); + return; + } + if (context.aiGatewayTab != null) { + controller.openAiGateway(tab: context.aiGatewayTab!); + return; + } + if (context.settingsTab != null || + context.destination == WorkspaceDestination.settings) { + controller.openSettings(tab: context.settingsTab ?? SettingsTab.general); + return; + } + controller.navigateTo(context.destination); +} diff --git a/lib/app/workspace_page_registry.dart b/lib/app/workspace_page_registry.dart new file mode 100644 index 00000000..cab10b7b --- /dev/null +++ b/lib/app/workspace_page_registry.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../features/account/account_page.dart'; +import '../features/ai_gateway/ai_gateway_page.dart'; +import '../features/assistant/assistant_page.dart'; +import '../features/claw_hub/claw_hub_page.dart'; +import '../features/mcp_server/mcp_server_page.dart'; +import '../features/modules/modules_page.dart'; +import '../features/secrets/secrets_page.dart'; +import '../features/settings/settings_page.dart'; +import '../features/skills/skills_page.dart'; +import '../features/tasks/tasks_page.dart'; +import '../models/app_models.dart'; +import '../widgets/assistant_focus_panel.dart'; +import 'app_controller.dart'; + +enum WorkspacePageSurface { desktop, mobile } + +typedef WorkspacePageBuilder = + Widget Function( + AppController controller, + ValueChanged onOpenDetail, + ); + +class WorkspacePageSpec { + const WorkspacePageSpec({ + required this.destination, + required this.desktopBuilder, + required this.mobileBuilder, + }); + + final WorkspaceDestination destination; + final WorkspacePageBuilder desktopBuilder; + final WorkspacePageBuilder mobileBuilder; +} + +final Map _workspacePageSpecs = + { + WorkspaceDestination.assistant: WorkspacePageSpec( + destination: WorkspaceDestination.assistant, + desktopBuilder: (controller, onOpenDetail) => AssistantPage( + controller: controller, + onOpenDetail: onOpenDetail, + navigationPanelBuilder: + controller.sidebarState == AppSidebarState.hidden + ? null + : (_) => AssistantFocusPanel(controller: controller), + showStandaloneTaskRail: false, + unifiedPaneStartsCollapsed: + controller.sidebarState == AppSidebarState.collapsed, + ), + mobileBuilder: (controller, onOpenDetail) => AssistantPage( + controller: controller, + onOpenDetail: onOpenDetail, + showStandaloneTaskRail: false, + ), + ), + WorkspaceDestination.tasks: WorkspacePageSpec( + destination: WorkspaceDestination.tasks, + desktopBuilder: (controller, onOpenDetail) => + TasksPage(controller: controller, onOpenDetail: onOpenDetail), + mobileBuilder: (controller, onOpenDetail) => + TasksPage(controller: controller, onOpenDetail: onOpenDetail), + ), + WorkspaceDestination.skills: WorkspacePageSpec( + destination: WorkspaceDestination.skills, + desktopBuilder: (controller, onOpenDetail) => + SkillsPage(controller: controller, onOpenDetail: onOpenDetail), + mobileBuilder: (controller, onOpenDetail) => + SkillsPage(controller: controller, onOpenDetail: onOpenDetail), + ), + WorkspaceDestination.nodes: WorkspacePageSpec( + destination: WorkspaceDestination.nodes, + desktopBuilder: (controller, onOpenDetail) => ModulesPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.modulesTab, + ), + mobileBuilder: (controller, onOpenDetail) => ModulesPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.modulesTab, + ), + ), + WorkspaceDestination.agents: WorkspacePageSpec( + destination: WorkspaceDestination.agents, + desktopBuilder: (controller, onOpenDetail) => ModulesPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.modulesTab, + ), + mobileBuilder: (controller, onOpenDetail) => ModulesPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.modulesTab, + ), + ), + WorkspaceDestination.mcpServer: WorkspacePageSpec( + destination: WorkspaceDestination.mcpServer, + desktopBuilder: (controller, onOpenDetail) => + McpServerPage(controller: controller, onOpenDetail: onOpenDetail), + mobileBuilder: (controller, onOpenDetail) => + McpServerPage(controller: controller, onOpenDetail: onOpenDetail), + ), + WorkspaceDestination.clawHub: WorkspacePageSpec( + destination: WorkspaceDestination.clawHub, + desktopBuilder: (controller, onOpenDetail) => + ClawHubPage(controller: controller, onOpenDetail: onOpenDetail), + mobileBuilder: (controller, onOpenDetail) => + ClawHubPage(controller: controller, onOpenDetail: onOpenDetail), + ), + WorkspaceDestination.secrets: WorkspacePageSpec( + destination: WorkspaceDestination.secrets, + desktopBuilder: (controller, onOpenDetail) => SecretsPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.secretsTab, + ), + mobileBuilder: (controller, onOpenDetail) => SecretsPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.secretsTab, + ), + ), + WorkspaceDestination.aiGateway: WorkspacePageSpec( + destination: WorkspaceDestination.aiGateway, + desktopBuilder: (controller, onOpenDetail) => AiGatewayPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.aiGatewayTab, + ), + mobileBuilder: (controller, onOpenDetail) => AiGatewayPage( + controller: controller, + onOpenDetail: onOpenDetail, + initialTab: controller.aiGatewayTab, + ), + ), + WorkspaceDestination.settings: WorkspacePageSpec( + destination: WorkspaceDestination.settings, + desktopBuilder: (controller, onOpenDetail) => SettingsPage( + controller: controller, + initialTab: controller.settingsTab, + initialDetail: controller.settingsDetail, + navigationContext: controller.settingsNavigationContext, + ), + mobileBuilder: (controller, onOpenDetail) => SettingsPage( + controller: controller, + initialTab: controller.settingsTab, + initialDetail: controller.settingsDetail, + navigationContext: controller.settingsNavigationContext, + ), + ), + WorkspaceDestination.account: WorkspacePageSpec( + destination: WorkspaceDestination.account, + desktopBuilder: (controller, onOpenDetail) => + AccountPage(controller: controller), + mobileBuilder: (controller, onOpenDetail) => + AccountPage(controller: controller), + ), + }; + +Widget buildWorkspacePage({ + required WorkspaceDestination destination, + required AppController controller, + required ValueChanged onOpenDetail, + required WorkspacePageSurface surface, +}) { + final spec = _workspacePageSpecs[destination]!; + return switch (surface) { + WorkspacePageSurface.desktop => spec.desktopBuilder( + controller, + onOpenDetail, + ), + WorkspacePageSurface.mobile => spec.mobileBuilder(controller, onOpenDetail), + }; +} diff --git a/lib/features/ai_gateway/ai_gateway_page.dart b/lib/features/ai_gateway/ai_gateway_page.dart index c211304a..34191cdc 100644 --- a/lib/features/ai_gateway/ai_gateway_page.dart +++ b/lib/features/ai_gateway/ai_gateway_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; +import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/platform_environment.dart'; @@ -16,17 +17,34 @@ class AiGatewayPage extends StatefulWidget { super.key, required this.controller, required this.onOpenDetail, + this.initialTab, }); final AppController controller; final ValueChanged onOpenDetail; + final AiGatewayTab? initialTab; @override State createState() => _AiGatewayPageState(); } class _AiGatewayPageState extends State { - AiGatewayTab _tab = AiGatewayTab.models; + late AiGatewayTab _tab; + + @override + void initState() { + super.initState(); + _tab = widget.initialTab ?? widget.controller.aiGatewayTab; + } + + @override + void didUpdateWidget(covariant AiGatewayPage oldWidget) { + super.didUpdateWidget(oldWidget); + final nextTab = widget.initialTab ?? widget.controller.aiGatewayTab; + if (nextTab != _tab) { + setState(() => _tab = nextTab); + } + } @override Widget build(BuildContext context) { @@ -67,20 +85,24 @@ class _AiGatewayPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, - ), - const AppBreadcrumbItem(label: 'AI Gateway'), - AppBreadcrumbItem(label: _tab.label), - ], + breadcrumbs: buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: 'AI Gateway', + sectionLabel: _tab.label, + ), title: 'AI Gateway', subtitle: appText( 'AI 代理与模型网关配置管理中心。', 'AI proxy and model gateway configuration center.', ), + trailing: FilledButton.tonalIcon( + onPressed: () => controller.openSettings( + detail: _aiGatewayDetailForTab(_tab), + navigationContext: _aiGatewayNavigationContext(_tab), + ), + icon: const Icon(Icons.tune_rounded), + label: Text(appText('编辑设置', 'Edit settings')), + ), ), const SizedBox(height: 24), Wrap( @@ -92,11 +114,12 @@ class _AiGatewayPageState extends State { SectionTabs( items: AiGatewayTab.values.map((t) => t.label).toList(), value: _tab.label, - onChanged: (label) => setState( - () => _tab = AiGatewayTab.values.firstWhere( + onChanged: (label) => setState(() { + _tab = AiGatewayTab.values.firstWhere( (t) => t.label == label, - ), - ), + ); + controller.openAiGateway(tab: _tab); + }), ), const SizedBox(height: 16), _buildTabContent(context, _tab, controller), @@ -140,7 +163,12 @@ class _AiGatewayPageState extends State { ), const Spacer(), FilledButton.icon( - onPressed: () {}, + onPressed: () => controller.openSettings( + detail: SettingsDetailPage.aiGatewayIntegration, + navigationContext: _aiGatewayNavigationContext( + AiGatewayTab.models, + ), + ), icon: const Icon(Icons.add_rounded, size: 18), label: Text(appText('添加模型', 'Add Model')), ), @@ -188,7 +216,12 @@ class _AiGatewayPageState extends State { ), const Spacer(), FilledButton.icon( - onPressed: () {}, + onPressed: () => controller.openSettings( + detail: SettingsDetailPage.externalAgents, + navigationContext: _aiGatewayNavigationContext( + AiGatewayTab.agents, + ), + ), icon: const Icon(Icons.add_rounded, size: 18), label: Text(appText('添加代理', 'Add Agent')), ), @@ -279,7 +312,7 @@ class _AiGatewayPageState extends State { ], ), const SizedBox(height: 16), - _CodexIntegrationCard(controller: controller), + _CodexIntegrationSummaryCard(controller: controller), ], ), ), @@ -309,14 +342,20 @@ class _AiGatewayPageState extends State { } } -enum AiGatewayTab { models, agents, endpoints, tools } +SettingsNavigationContext _aiGatewayNavigationContext(AiGatewayTab tab) { + return SettingsNavigationContext( + rootLabel: 'AI Gateway', + destination: WorkspaceDestination.aiGateway, + sectionLabel: tab.label, + aiGatewayTab: tab, + ); +} -extension AiGatewayTabCopy on AiGatewayTab { - String get label => switch (this) { - AiGatewayTab.models => appText('模型', 'Models'), - AiGatewayTab.agents => appText('代理', 'Agents'), - AiGatewayTab.endpoints => appText('端点', 'Endpoints'), - AiGatewayTab.tools => appText('工具', 'Tools'), +SettingsDetailPage _aiGatewayDetailForTab(AiGatewayTab tab) { + return switch (tab) { + AiGatewayTab.agents || + AiGatewayTab.tools => SettingsDetailPage.externalAgents, + _ => SettingsDetailPage.aiGatewayIntegration, }; } @@ -469,16 +508,100 @@ class _EndpointCard extends StatelessWidget { // Codex Integration Section // ============================================ -class _CodexIntegrationCard extends StatefulWidget { - const _CodexIntegrationCard({required this.controller}); +class _CodexIntegrationSummaryCard extends StatelessWidget { + const _CodexIntegrationSummaryCard({required this.controller}); final AppController controller; @override - State<_CodexIntegrationCard> createState() => _CodexIntegrationCardState(); + Widget build(BuildContext context) { + final palette = context.palette; + final cooperationLabel = switch (controller.codexCooperationState) { + CodexCooperationState.notStarted => appText('未启动', 'Not started'), + CodexCooperationState.bridgeOnly => appText( + '已启动,但未注册到 Gateway', + 'Started, not registered to the gateway', + ), + CodexCooperationState.registered => appText( + '已启动并已注册到 Gateway', + 'Started and registered to the gateway', + ), + }; + + return Card( + color: palette.surfaceSecondary, + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('Codex CLI 集成', 'Codex CLI Integration'), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: palette.textPrimary, + ), + ), + const SizedBox(height: 10), + Text( + appText( + '二级页只保留运行状态和快速入口,详细参数统一进入 Settings detail。', + 'The status page keeps only runtime state and quick entry points. Detailed parameters live in Settings detail.', + ), + style: TextStyle(fontSize: 13, color: palette.textSecondary), + ), + const SizedBox(height: 16), + _StatusRow( + label: appText('运行时模式', 'Runtime mode'), + value: controller.effectiveCodeAgentRuntimeMode.label, + ), + _StatusRow( + label: appText('Bridge 状态', 'Bridge status'), + value: controller.isCodexBridgeEnabled + ? appText('运行中', 'Running') + : appText('未启用', 'Disabled'), + ), + _StatusRow( + label: appText('Gateway 协同状态', 'Gateway cooperation'), + value: cooperationLabel, + ), + _StatusRow( + label: appText('Binary 状态', 'Binary status'), + value: controller.hasDetectedCodexCli + ? appText('已就绪', 'Ready') + : appText('未检测到', 'Not found'), + detail: controller.resolvedCodexCliPath, + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => controller.openSettings( + detail: SettingsDetailPage.externalAgents, + navigationContext: _aiGatewayNavigationContext( + AiGatewayTab.tools, + ), + ), + icon: const Icon(Icons.tune_rounded), + label: Text(appText('编辑详细设置', 'Edit detailed settings')), + ), + ], + ), + ), + ); + } } -class _CodexIntegrationCardState extends State<_CodexIntegrationCard> { +class CodexIntegrationCard extends StatefulWidget { + const CodexIntegrationCard({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _CodexIntegrationCardState(); +} + +class _CodexIntegrationCardState extends State { bool _isExporting = false; String? _exportPath; String? _errorMessage; @@ -493,7 +616,7 @@ class _CodexIntegrationCardState extends State<_CodexIntegrationCard> { } @override - void didUpdateWidget(covariant _CodexIntegrationCard oldWidget) { + void didUpdateWidget(covariant CodexIntegrationCard oldWidget) { super.didUpdateWidget(oldWidget); final nextValue = widget.controller.configuredCodexCliPath; if (_pathController.text != nextValue) { diff --git a/lib/features/mobile/mobile_shell.dart b/lib/features/mobile/mobile_shell.dart index c49c9443..8da26de8 100644 --- a/lib/features/mobile/mobile_shell.dart +++ b/lib/features/mobile/mobile_shell.dart @@ -1,16 +1,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; -import '../../features/account/account_page.dart'; -import '../../features/ai_gateway/ai_gateway_page.dart'; -import '../../features/assistant/assistant_page.dart'; -import '../../features/claw_hub/claw_hub_page.dart'; -import '../../features/mcp_server/mcp_server_page.dart'; -import '../../features/modules/modules_page.dart'; -import '../../features/secrets/secrets_page.dart'; -import '../../features/settings/settings_page.dart'; -import '../../features/skills/skills_page.dart'; -import '../../features/tasks/tasks_page.dart'; +import '../../app/workspace_page_registry.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_models.dart'; @@ -169,53 +160,12 @@ class _MobileShellState extends State { } final destination = widget.controller.destination; - return switch (destination) { - WorkspaceDestination.assistant => AssistantPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - showStandaloneTaskRail: false, - ), - WorkspaceDestination.tasks => TasksPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - ), - WorkspaceDestination.skills => SkillsPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - ), - WorkspaceDestination.nodes => ModulesPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - initialTab: ModulesTab.nodes, - ), - WorkspaceDestination.agents => ModulesPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - initialTab: ModulesTab.agents, - ), - WorkspaceDestination.mcpServer => McpServerPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - ), - WorkspaceDestination.clawHub => ClawHubPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - ), - WorkspaceDestination.secrets => SecretsPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - ), - WorkspaceDestination.aiGateway => AiGatewayPage( - controller: widget.controller, - onOpenDetail: _openDetailSheet, - ), - WorkspaceDestination.settings => SettingsPage( - controller: widget.controller, - ), - WorkspaceDestination.account => AccountPage( - controller: widget.controller, - ), - }; + return buildWorkspacePage( + destination: destination, + controller: widget.controller, + onOpenDetail: _openDetailSheet, + surface: WorkspacePageSurface.mobile, + ); } @override diff --git a/lib/features/modules/modules_page.dart b/lib/features/modules/modules_page.dart index ab6c682c..219fd9e6 100644 --- a/lib/features/modules/modules_page.dart +++ b/lib/features/modules/modules_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; +import '../../app/workspace_navigation.dart'; import '../../app/app_metadata.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; @@ -13,7 +14,7 @@ import '../../widgets/status_badge.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; - class ModulesPage extends StatefulWidget { +class ModulesPage extends StatefulWidget { const ModulesPage({ super.key, required this.controller, @@ -29,14 +30,21 @@ import '../../widgets/top_bar.dart'; State createState() => _ModulesPageState(); } - class _ModulesPageState extends State { - ModulesTab _tab = ModulesTab.gateway; +class _ModulesPageState extends State { + late ModulesTab _tab; @override void initState() { super.initState(); - if (widget.initialTab != null) { - _tab = widget.initialTab!; + _tab = widget.initialTab ?? widget.controller.modulesTab; + } + + @override + void didUpdateWidget(covariant ModulesPage oldWidget) { + super.didUpdateWidget(oldWidget); + final nextTab = widget.initialTab ?? widget.controller.modulesTab; + if (nextTab != _tab) { + setState(() => _tab = nextTab); } } @@ -77,15 +85,11 @@ import '../../widgets/top_bar.dart'; crossAxisAlignment: CrossAxisAlignment.start, children: [ TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, - ), - AppBreadcrumbItem(label: appText('模块', 'Modules')), - AppBreadcrumbItem(label: _tab.label), - ], + breadcrumbs: buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: appText('模块', 'Modules'), + sectionLabel: _tab.label, + ), title: appText('模块', 'Modules'), subtitle: appText( '管理 Gateway、代理、节点、技能和平台服务。', @@ -123,7 +127,7 @@ import '../../widgets/top_bar.dart'; ), FilledButton.tonalIcon( onPressed: () => - controller.navigateTo(WorkspaceDestination.settings), + controller.openSettings(tab: SettingsTab.gateway), icon: const Icon(Icons.add_rounded), label: Text(appText('接入模块', 'Add Module')), ), @@ -134,11 +138,12 @@ import '../../widgets/top_bar.dart'; SectionTabs( items: ModulesTab.values.map((item) => item.label).toList(), value: _tab.label, - onChanged: (value) => setState( - () => _tab = ModulesTab.values.firstWhere( + onChanged: (value) => setState(() { + _tab = ModulesTab.values.firstWhere( (item) => item.label == value, - ), - ), + ); + controller.openModules(tab: _tab); + }), ), const SizedBox(height: 24), LayoutBuilder( @@ -197,6 +202,22 @@ import '../../widgets/top_bar.dart'; } } +SettingsNavigationContext _modulesNavigationContext(ModulesTab tab) { + return SettingsNavigationContext( + rootLabel: appText('模块', 'Modules'), + destination: WorkspaceDestination.nodes, + sectionLabel: tab.label, + modulesTab: tab, + ); +} + +SettingsDetailPage _modulesDetailForTab(ModulesTab tab) { + return switch (tab) { + ModulesTab.agents => SettingsDetailPage.externalAgents, + _ => SettingsDetailPage.gatewayConnection, + }; +} + class _GatewayPanel extends StatelessWidget { const _GatewayPanel({required this.controller, required this.onOpenDetail}); @@ -338,9 +359,13 @@ class _GatewayPanel extends StatelessWidget { child: Text(appText('刷新会话', 'Refresh sessions')), ), OutlinedButton( - onPressed: () => - controller.navigateTo(WorkspaceDestination.settings), - child: Text(appText('配置', 'Configure')), + onPressed: () => controller.openSettings( + detail: _modulesDetailForTab(ModulesTab.gateway), + navigationContext: _modulesNavigationContext( + ModulesTab.gateway, + ), + ), + child: Text(appText('编辑设置', 'Edit settings')), ), ], ), diff --git a/lib/features/secrets/secrets_page.dart b/lib/features/secrets/secrets_page.dart index 32d39e60..d6256334 100644 --- a/lib/features/secrets/secrets_page.dart +++ b/lib/features/secrets/secrets_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; +import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_models.dart'; @@ -16,17 +17,34 @@ class SecretsPage extends StatefulWidget { super.key, required this.controller, required this.onOpenDetail, + this.initialTab, }); final AppController controller; final ValueChanged onOpenDetail; + final SecretsTab? initialTab; @override State createState() => _SecretsPageState(); } class _SecretsPageState extends State { - SecretsTab _tab = SecretsTab.vault; + late SecretsTab _tab; + + @override + void initState() { + super.initState(); + _tab = widget.initialTab ?? widget.controller.secretsTab; + } + + @override + void didUpdateWidget(covariant SecretsPage oldWidget) { + super.didUpdateWidget(oldWidget); + final nextTab = widget.initialTab ?? widget.controller.secretsTab; + if (nextTab != _tab) { + setState(() => _tab = nextTab); + } + } @override Widget build(BuildContext context) { @@ -40,15 +58,11 @@ class _SecretsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, - ), - AppBreadcrumbItem(label: appText('密钥', 'Secrets')), - AppBreadcrumbItem(label: _tab.label), - ], + breadcrumbs: buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: appText('密钥', 'Secrets'), + sectionLabel: _tab.label, + ), title: appText('密钥', 'Secrets'), subtitle: appText( '管理密钥提供方、凭证和模块间的安全引用。', @@ -76,7 +90,7 @@ class _SecretsPageState extends State { ), FilledButton.tonalIcon( onPressed: () => - controller.navigateTo(WorkspaceDestination.settings), + controller.openSettings(tab: SettingsTab.gateway), icon: const Icon(Icons.add_rounded), label: Text(appText('新增密钥', 'Add Secret')), ), @@ -87,11 +101,12 @@ class _SecretsPageState extends State { SectionTabs( items: SecretsTab.values.map((item) => item.label).toList(), value: _tab.label, - onChanged: (value) => setState( - () => _tab = SecretsTab.values.firstWhere( + onChanged: (value) => setState(() { + _tab = SecretsTab.values.firstWhere( (item) => item.label == value, - ), - ), + ); + controller.openSecrets(tab: _tab); + }), ), const SizedBox(height: 24), switch (_tab) { @@ -120,6 +135,24 @@ class _SecretsPageState extends State { } } +SettingsNavigationContext _secretsNavigationContext(SecretsTab tab) { + return SettingsNavigationContext( + rootLabel: appText('密钥', 'Secrets'), + destination: WorkspaceDestination.secrets, + sectionLabel: tab.label, + secretsTab: tab, + ); +} + +SettingsDetailPage _secretsDetailForTab(SecretsTab tab) { + return switch (tab) { + SecretsTab.vault => SettingsDetailPage.vaultProvider, + SecretsTab.providers => SettingsDetailPage.ollamaProvider, + SecretsTab.audit => SettingsDetailPage.diagnosticsAdvanced, + SecretsTab.localStore => SettingsDetailPage.ollamaProvider, + }; +} + class _VaultPanel extends StatelessWidget { const _VaultPanel({required this.controller, required this.onOpenDetail}); @@ -203,9 +236,13 @@ class _VaultPanel extends StatelessWidget { child: Text(appText('连接测试', 'Test Connection')), ), OutlinedButton( - onPressed: () => - controller.navigateTo(WorkspaceDestination.settings), - child: Text(appText('配置', 'Configure')), + onPressed: () => controller.openSettings( + detail: _secretsDetailForTab(SecretsTab.vault), + navigationContext: _secretsNavigationContext( + SecretsTab.vault, + ), + ), + child: Text(appText('编辑设置', 'Edit settings')), ), ], ), diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 5b10b58e..be9e6cb1 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_metadata.dart'; +import '../../app/workspace_navigation.dart'; +import '../ai_gateway/ai_gateway_page.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_controllers.dart'; @@ -18,10 +20,14 @@ class SettingsPage extends StatefulWidget { super.key, required this.controller, this.initialTab = SettingsTab.general, + this.initialDetail, + this.navigationContext, }); final AppController controller; final SettingsTab initialTab; + final SettingsDetailPage? initialDetail; + final SettingsNavigationContext? navigationContext; @override State createState() => _SettingsPageState(); @@ -31,6 +37,8 @@ class _SettingsPageState extends State { static const _storedSecretMask = '****'; late SettingsTab _tab; + SettingsDetailPage? _detail; + SettingsNavigationContext? _navigationContext; late final TextEditingController _aiGatewayNameController; late final TextEditingController _aiGatewayUrlController; late final TextEditingController _aiGatewayApiKeyRefController; @@ -55,6 +63,8 @@ class _SettingsPageState extends State { void initState() { super.initState(); _tab = widget.initialTab; + _detail = widget.initialDetail; + _navigationContext = widget.navigationContext; _aiGatewayNameController = TextEditingController(); _aiGatewayUrlController = TextEditingController(); _aiGatewayApiKeyRefController = TextEditingController(); @@ -65,6 +75,20 @@ class _SettingsPageState extends State { _runtimeLogFilterController = TextEditingController(); } + @override + void didUpdateWidget(covariant SettingsPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialTab != _tab) { + _tab = widget.initialTab; + } + if (widget.initialDetail != _detail) { + _detail = widget.initialDetail; + } + if (widget.navigationContext != _navigationContext) { + _navigationContext = widget.navigationContext; + } + } + @override void dispose() { _aiGatewayNameController.dispose(); @@ -84,81 +108,72 @@ class _SettingsPageState extends State { return AnimatedBuilder( animation: controller, builder: (context, _) { + _tab = controller.settingsTab; + _detail = controller.settingsDetail; + _navigationContext = controller.settingsNavigationContext; final settings = controller.settings; + final showingDetail = _detail != null; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, - ), - AppBreadcrumbItem(label: appText('设置', 'Settings')), - AppBreadcrumbItem(label: _tab.label), - ], + breadcrumbs: buildSettingsBreadcrumbs( + controller, + tab: _tab, + detail: _detail, + navigationContext: _navigationContext, + ), title: appText('设置', 'Settings'), - subtitle: appText( - '配置 $kProductBrandName 工作区、网关默认项、界面与诊断选项', - 'Configure workspace, gateway defaults, appearance, and diagnostics for $kProductBrandName.', - ), + subtitle: showingDetail + ? appText( + '当前正在编辑详细设置参数,保存后会回写到对应状态页。', + 'You are editing detailed settings. Saved values flow back to the related status page.', + ) + : appText( + '配置 $kProductBrandName 工作区、网关默认项、界面与诊断选项', + 'Configure workspace, gateway defaults, appearance, and diagnostics for $kProductBrandName.', + ), trailing: SizedBox( - width: 220, - child: TextField( - decoration: InputDecoration( - hintText: appText('搜索设置', 'Search settings'), - prefixIcon: Icon(Icons.search_rounded), - ), - ), + width: showingDetail ? 168 : 220, + child: showingDetail + ? OutlinedButton.icon( + onPressed: () { + controller.closeSettingsDetail(); + setState(() { + _detail = null; + _navigationContext = null; + }); + }, + icon: const Icon(Icons.arrow_back_rounded), + label: Text(appText('返回概览', 'Back to overview')), + ) + : TextField( + decoration: InputDecoration( + hintText: appText('搜索设置', 'Search settings'), + prefixIcon: Icon(Icons.search_rounded), + ), + ), ), ), const SizedBox(height: 24), - SectionTabs( - items: SettingsTab.values.map((item) => item.label).toList(), - value: _tab.label, - onChanged: (value) => setState( - () => _tab = SettingsTab.values.firstWhere( - (item) => item.label == value, - ), + if (!showingDetail) ...[ + SectionTabs( + items: SettingsTab.values.map((item) => item.label).toList(), + value: _tab.label, + onChanged: (value) => setState(() { + _tab = SettingsTab.values.firstWhere( + (item) => item.label == value, + ); + _detail = null; + _navigationContext = null; + controller.setSettingsTab(_tab); + }), ), - ), - const SizedBox(height: 24), - ...switch (_tab) { - SettingsTab.general => _buildGeneral( - context, - controller, - settings, - ), - SettingsTab.workspace => _buildWorkspace( - context, - controller, - settings, - ), - SettingsTab.gateway => _buildGateway( - context, - controller, - settings, - ), - SettingsTab.agents => _buildAgents( - context, - controller, - settings, - ), - SettingsTab.appearance => _buildAppearance(context, controller), - SettingsTab.diagnostics => _buildDiagnostics( - context, - controller, - ), - SettingsTab.experimental => _buildExperimental( - context, - controller, - settings, - ), - SettingsTab.about => _buildAbout(context, controller), - }, + const SizedBox(height: 24), + ], + ..._buildContentForCurrentState(context, controller, settings), ], ), ); @@ -166,6 +181,134 @@ class _SettingsPageState extends State { ); } + List _buildContentForCurrentState( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + if (_detail != null) { + return _buildDetailContent(context, controller, settings, _detail!); + } + + return switch (_tab) { + SettingsTab.general => _buildGeneral(context, controller, settings), + SettingsTab.workspace => _buildWorkspace(context, controller, settings), + SettingsTab.gateway => _buildGateway(context, controller, settings), + SettingsTab.agents => _buildAgents(context, controller, settings), + SettingsTab.appearance => _buildAppearance(context, controller), + SettingsTab.diagnostics => _buildDiagnostics(context, controller), + SettingsTab.experimental => _buildExperimental( + context, + controller, + settings, + ), + SettingsTab.about => _buildAbout(context, controller), + }; + } + + List _buildDetailContent( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + SettingsDetailPage detail, + ) { + final gatewaySections = _buildGateway(context, controller, settings); + final workspaceSections = _buildWorkspace(context, controller, settings); + return switch (detail) { + SettingsDetailPage.gatewayConnection => [ + _buildDetailIntro( + context, + title: detail.label, + description: appText( + '集中编辑 Gateway 连接、设备配对和会话级连接入口。', + 'Edit gateway connection, device pairing, and session-level connection entry points in one place.', + ), + ), + const SizedBox(height: 16), + ...gatewaySections.take(3), + ], + SettingsDetailPage.aiGatewayIntegration => [ + _buildDetailIntro( + context, + title: detail.label, + description: appText( + '统一管理 AI Gateway 地址、API Key、模型目录同步和默认选择。', + 'Manage AI Gateway endpoint, API key, model catalog sync, and default selections from one screen.', + ), + ), + const SizedBox(height: 16), + if (gatewaySections.isNotEmpty) gatewaySections.last, + ], + SettingsDetailPage.vaultProvider => [ + _buildDetailIntro( + context, + title: detail.label, + description: appText( + '只在这里维护 Vault 地址、命名空间和安全 token 引用。', + 'Maintain Vault endpoint, namespace, and secure token references here.', + ), + ), + const SizedBox(height: 16), + if (gatewaySections.length > 4) gatewaySections[4], + ], + SettingsDetailPage.ollamaProvider => [ + _buildDetailIntro( + context, + title: detail.label, + description: appText( + '本地与云端 Ollama 提供方参数统一放在这个 detail 页面中维护。', + 'Local and cloud Ollama provider settings live in this dedicated detail page.', + ), + ), + const SizedBox(height: 16), + ...workspaceSections.skip(1), + ], + SettingsDetailPage.externalAgents => [ + _buildDetailIntro( + context, + title: detail.label, + description: appText( + '多 Agent 协作、角色编排和外部 CLI 工具的详细参数集中在这里。', + 'Detailed multi-agent collaboration, role orchestration, and external CLI settings are edited here.', + ), + ), + const SizedBox(height: 16), + ..._buildAgents(context, controller, settings), + const SizedBox(height: 16), + CodexIntegrationCard(controller: controller), + ], + SettingsDetailPage.diagnosticsAdvanced => [ + _buildDetailIntro( + context, + title: detail.label, + description: appText( + '高级诊断集中展示网关诊断、运行日志和设备信息。', + 'Advanced diagnostics centralize gateway diagnostics, runtime logs, and device information.', + ), + ), + const SizedBox(height: 16), + ..._buildDiagnostics(context, controller), + ], + }; + } + + Widget _buildDetailIntro( + BuildContext context, { + required String title, + required String description, + }) { + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 10), + Text(description, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } + List _buildGeneral( BuildContext context, AppController controller, diff --git a/lib/features/skills/skills_page.dart b/lib/features/skills/skills_page.dart index 6f8c5f05..c7103d2d 100644 --- a/lib/features/skills/skills_page.dart +++ b/lib/features/skills/skills_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; +import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_models.dart'; @@ -45,6 +46,10 @@ class _SkillsPageState extends State { .toList(growable: false); final selected = _resolveSelectedSkill(skills); return DesktopWorkspaceScaffold( + breadcrumbs: buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: WorkspaceDestination.skills.label, + ), eyebrow: appText('技能与能力包', 'Skills and capabilities'), title: appText('技能工作台', 'Skills workspace'), subtitle: appText( diff --git a/lib/features/tasks/tasks_page.dart b/lib/features/tasks/tasks_page.dart index 17ca3aab..fffd7870 100644 --- a/lib/features/tasks/tasks_page.dart +++ b/lib/features/tasks/tasks_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; +import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_models.dart'; @@ -77,6 +78,10 @@ class _TasksPageState extends State { builder: (context, _) { final palette = context.palette; return DesktopWorkspaceScaffold( + breadcrumbs: buildWorkspaceBreadcrumbs( + controller: controller, + rootLabel: WorkspaceDestination.tasks.label, + ), eyebrow: appText('任务与线程', 'Tasks and sessions'), title: appText('任务工作台', 'Task workspace'), subtitle: appText( diff --git a/lib/models/app_models.dart b/lib/models/app_models.dart index 5b71c368..6a112f14 100644 --- a/lib/models/app_models.dart +++ b/lib/models/app_models.dart @@ -215,6 +215,85 @@ extension SettingsTabCopy on SettingsTab { }; } +enum AiGatewayTab { models, agents, endpoints, tools } + +extension AiGatewayTabCopy on AiGatewayTab { + String get label => switch (this) { + AiGatewayTab.models => appText('模型', 'Models'), + AiGatewayTab.agents => appText('代理', 'Agents'), + AiGatewayTab.endpoints => appText('端点', 'Endpoints'), + AiGatewayTab.tools => appText('工具', 'Tools'), + }; +} + +enum SettingsDetailPage { + gatewayConnection, + aiGatewayIntegration, + vaultProvider, + ollamaProvider, + externalAgents, + diagnosticsAdvanced, +} + +extension SettingsDetailPageCopy on SettingsDetailPage { + String get label => switch (this) { + SettingsDetailPage.gatewayConnection => appText( + 'Gateway 连接参数', + 'Gateway Connection', + ), + SettingsDetailPage.aiGatewayIntegration => appText( + 'AI Gateway 集成参数', + 'AI Gateway Integration', + ), + SettingsDetailPage.vaultProvider => appText( + 'Vault 提供方参数', + 'Vault Provider', + ), + SettingsDetailPage.ollamaProvider => appText( + 'Ollama 提供方参数', + 'Ollama Provider', + ), + SettingsDetailPage.externalAgents => appText( + '多 Agent 协作参数', + 'External Agents', + ), + SettingsDetailPage.diagnosticsAdvanced => appText( + '高级诊断参数', + 'Advanced Diagnostics', + ), + }; + + SettingsTab get tab => switch (this) { + SettingsDetailPage.gatewayConnection || + SettingsDetailPage.aiGatewayIntegration || + SettingsDetailPage.vaultProvider => SettingsTab.gateway, + SettingsDetailPage.ollamaProvider => SettingsTab.workspace, + SettingsDetailPage.externalAgents => SettingsTab.agents, + SettingsDetailPage.diagnosticsAdvanced => SettingsTab.diagnostics, + }; +} + +@immutable +class SettingsNavigationContext { + const SettingsNavigationContext({ + required this.rootLabel, + required this.destination, + this.sectionLabel, + this.modulesTab, + this.secretsTab, + this.aiGatewayTab, + this.settingsTab, + }); + + final String rootLabel; + final WorkspaceDestination destination; + final String? sectionLabel; + final ModulesTab? modulesTab; + final SecretsTab? secretsTab; + final AiGatewayTab? aiGatewayTab; + final SettingsTab? settingsTab; +} + enum AccountTab { profile, workspace, sessions } extension AccountTabCopy on AccountTab { diff --git a/lib/widgets/desktop_workspace_scaffold.dart b/lib/widgets/desktop_workspace_scaffold.dart index 0163a935..fcc3ac49 100644 --- a/lib/widgets/desktop_workspace_scaffold.dart +++ b/lib/widgets/desktop_workspace_scaffold.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import '../theme/app_palette.dart'; import '../theme/app_theme.dart'; +import 'top_bar.dart'; class DesktopWorkspaceScaffold extends StatelessWidget { const DesktopWorkspaceScaffold({ super.key, required this.child, + this.breadcrumbs = const [], this.eyebrow, this.title, this.subtitle, @@ -15,6 +17,7 @@ class DesktopWorkspaceScaffold extends StatelessWidget { }); final Widget child; + final List breadcrumbs; final String? eyebrow; final String? title; final String? subtitle; @@ -25,6 +28,7 @@ class DesktopWorkspaceScaffold extends StatelessWidget { Widget build(BuildContext context) { final palette = context.palette; final hasHeader = + breadcrumbs.isNotEmpty || (title != null && title!.trim().isNotEmpty) || (subtitle != null && subtitle!.trim().isNotEmpty) || toolbar != null; @@ -43,6 +47,10 @@ class DesktopWorkspaceScaffold extends StatelessWidget { final header = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (breadcrumbs.isNotEmpty) ...[ + AppBreadcrumbs(items: breadcrumbs), + const SizedBox(height: 10), + ], if (eyebrow != null && eyebrow!.trim().isNotEmpty) ...[ Text( eyebrow!, diff --git a/test/features/ai_gateway_page_test.dart b/test/features/ai_gateway_page_test.dart index 277857a2..57285651 100644 --- a/test/features/ai_gateway_page_test.dart +++ b/test/features/ai_gateway_page_test.dart @@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/ai_gateway/ai_gateway_page.dart'; +import 'package:xworkmate/features/settings/settings_page.dart'; +import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; @@ -14,6 +16,8 @@ import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; +import '../test_support.dart'; + class _FakeGatewayRuntime extends GatewayRuntime { _FakeGatewayRuntime() : super( @@ -50,84 +54,123 @@ class _FakeCodexRuntime extends CodexRuntime { } void main() { - testWidgets('AiGatewayPage shows Codex bridge runtime states', ( + testWidgets('AiGatewayPage edit settings opens detail context', ( WidgetTester tester, ) async { - late AppController controller; - await tester.runAsync(() async { - SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); - controller = AppController( - store: store, - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(), - codex: _FakeCodexRuntime(), - ), - ); - await _waitFor(() => !controller.initializing); - }); - addTearDown(() => controller.dispose()); + final controller = await createTestController(tester); - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1600, 1000); - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('zh'), - supportedLocales: const [Locale('zh'), Locale('en')], - localizationsDelegates: GlobalMaterialLocalizations.delegates, - theme: AppTheme.light(), - darkTheme: AppTheme.dark(), - home: Scaffold( - body: AiGatewayPage(controller: controller, onOpenDetail: (_) {}), - ), - ), + await pumpPage( + tester, + child: AiGatewayPage(controller: controller, onOpenDetail: (_) {}), ); - await tester.pump(); - await tester.tap(find.text('工具')); - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.text('External Codex CLI'), findsOneWidget); - expect(find.text('Built-in Codex (Experimental)'), findsOneWidget); - expect(find.text('未检测到'), findsOneWidget); - - await tester.tap( - find.widgetWithText(ChoiceChip, 'Built-in Codex (Experimental)'), - ); + await tester.tap(find.text('编辑设置')); await tester.pumpAndSettle(); - expect( - controller.settings.codeAgentRuntimeMode, - CodeAgentRuntimeMode.builtIn, - ); - late Directory tempDir; - late File codexBinary; - await tester.runAsync(() async { - tempDir = await Directory.systemTemp.createTemp('codex-ai-gateway-page-'); - codexBinary = File('${tempDir.path}/codex'); - await codexBinary.writeAsString('#!/bin/sh\nexit 0\n'); - await controller.saveSettings( - controller.settings.copyWith( - codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, - codexCliPath: codexBinary.path, + expect(controller.destination, WorkspaceDestination.settings); + expect(controller.settingsDetail, SettingsDetailPage.aiGatewayIntegration); + expect( + controller.settingsNavigationContext?.aiGatewayTab, + AiGatewayTab.models, + ); + }); + + testWidgets( + 'Settings external agents detail shows Codex bridge runtime states', + (WidgetTester tester) async { + late AppController controller; + await tester.runAsync(() async { + SharedPreferences.setMockInitialValues({}); + final store = SecureConfigStore(); + controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(), + codex: _FakeCodexRuntime(), + ), + ); + await _waitFor(() => !controller.initializing); + }); + addTearDown(() => controller.dispose()); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + controller.openSettings( + detail: SettingsDetailPage.externalAgents, + navigationContext: SettingsNavigationContext( + rootLabel: 'AI Gateway', + destination: WorkspaceDestination.aiGateway, + sectionLabel: AiGatewayTab.tools.label, + aiGatewayTab: AiGatewayTab.tools, ), ); - }); - addTearDown(() async { - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - }); - await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('已就绪'), findsOneWidget); - expect(find.text(codexBinary.path), findsAtLeastNWidgets(1)); - }); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + supportedLocales: const [Locale('zh'), Locale('en')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + home: Scaffold( + body: SettingsPage( + controller: controller, + initialTab: controller.settingsTab, + initialDetail: controller.settingsDetail, + navigationContext: controller.settingsNavigationContext, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('External Codex CLI'), findsOneWidget); + expect(find.text('Built-in Codex (Experimental)'), findsOneWidget); + expect(find.text('未检测到'), findsOneWidget); + + final builtInChip = find.widgetWithText( + ChoiceChip, + 'Built-in Codex (Experimental)', + ); + await tester.ensureVisible(builtInChip); + await tester.tap(builtInChip); + await tester.pumpAndSettle(); + expect( + controller.settings.codeAgentRuntimeMode, + CodeAgentRuntimeMode.builtIn, + ); + + late Directory tempDir; + late File codexBinary; + await tester.runAsync(() async { + tempDir = await Directory.systemTemp.createTemp( + 'codex-ai-gateway-page-', + ); + codexBinary = File('${tempDir.path}/codex'); + await codexBinary.writeAsString('#!/bin/sh\nexit 0\n'); + await controller.saveSettings( + controller.settings.copyWith( + codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, + codexCliPath: codexBinary.path, + ), + ); + }); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('已就绪'), findsOneWidget); + expect(find.text(codexBinary.path), findsAtLeastNWidgets(1)); + }, + ); } Future _waitFor(bool Function() predicate) async { diff --git a/test/features/modules_page_test.dart b/test/features/modules_page_test.dart index fa221ccd..d87cf820 100644 --- a/test/features/modules_page_test.dart +++ b/test/features/modules_page_test.dart @@ -16,17 +16,27 @@ void main() { child: ModulesPage(controller: controller, onOpenDetail: (_) {}), ); - await tester.tap(find.text('配置').first); + await tester.tap(find.text('编辑设置').first); await tester.pumpAndSettle(); expect(controller.destination, WorkspaceDestination.settings); + expect(controller.settingsDetail, SettingsDetailPage.gatewayConnection); + expect( + controller.settingsNavigationContext?.modulesTab, + ModulesTab.gateway, + ); await tester.tap(find.text('连接器')); await tester.pumpAndSettle(); - expect(find.textContaining('连接 Gateway 后可加载连接器状态'), findsOneWidget); + expect( + find.textContaining('连接 Gateway 后可加载连接器状态'), + findsOneWidget, + ); await tester.tap(find.text('接入模块')); await tester.pumpAndSettle(); expect(controller.destination, WorkspaceDestination.settings); + expect(controller.settingsTab, SettingsTab.gateway); + expect(controller.settingsDetail, isNull); }, ); } diff --git a/test/features/settings_page_test.dart b/test/features/settings_page_test.dart index 971c15ec..8b629732 100644 --- a/test/features/settings_page_test.dart +++ b/test/features/settings_page_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/features/settings/settings_page.dart'; +import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; @@ -185,4 +186,38 @@ void main() { expect(controller.runtimeLogs, isEmpty); }); + + testWidgets('SettingsPage detail mode returns to overview', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + controller.openSettings( + detail: SettingsDetailPage.gatewayConnection, + navigationContext: SettingsNavigationContext( + rootLabel: '模块', + destination: WorkspaceDestination.nodes, + sectionLabel: ModulesTab.gateway.label, + modulesTab: ModulesTab.gateway, + ), + ); + + await pumpPage( + tester, + child: SettingsPage( + controller: controller, + initialTab: controller.settingsTab, + initialDetail: controller.settingsDetail, + navigationContext: controller.settingsNavigationContext, + ), + ); + + expect(find.text('Gateway 连接参数'), findsWidgets); + expect(find.text('返回概览'), findsOneWidget); + + await tester.tap(find.text('返回概览')); + await tester.pumpAndSettle(); + + expect(controller.settingsDetail, isNull); + expect(find.text('搜索设置'), findsOneWidget); + }); }