refactor: unify settings drill-in navigation

This commit is contained in:
Haitao Pan 2026-03-20 15:39:33 +08:00
parent 50b8a4dc98
commit e988c8e23b
17 changed files with 1288 additions and 329 deletions

137
docs/plans/2026-03-20.md Normal file
View File

@ -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` 首页主路由
- 二级页不再承载完整高级表单

View File

@ -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();

View File

@ -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<AppShell> {
WorkspaceDestination destination,
ValueChanged<DetailPanelData> 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,
);
}
}

View File

@ -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<AppBreadcrumbItem> buildWorkspaceBreadcrumbs({
required AppController controller,
required String rootLabel,
String? sectionLabel,
String? detailLabel,
VoidCallback? onRootTap,
}) {
final items = <AppBreadcrumbItem>[
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<AppBreadcrumbItem> 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);
}

View File

@ -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<DetailPanelData> 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<WorkspaceDestination, WorkspacePageSpec> _workspacePageSpecs =
<WorkspaceDestination, WorkspacePageSpec>{
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<DetailPanelData> onOpenDetail,
required WorkspacePageSurface surface,
}) {
final spec = _workspacePageSpecs[destination]!;
return switch (surface) {
WorkspacePageSurface.desktop => spec.desktopBuilder(
controller,
onOpenDetail,
),
WorkspacePageSurface.mobile => spec.mobileBuilder(controller, onOpenDetail),
};
}

View File

@ -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<DetailPanelData> onOpenDetail;
final AiGatewayTab? initialTab;
@override
State<AiGatewayPage> createState() => _AiGatewayPageState();
}
class _AiGatewayPageState extends State<AiGatewayPage> {
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<AiGatewayPage> {
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<AiGatewayPage> {
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<AiGatewayPage> {
),
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<AiGatewayPage> {
),
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<AiGatewayPage> {
],
),
const SizedBox(height: 16),
_CodexIntegrationCard(controller: controller),
_CodexIntegrationSummaryCard(controller: controller),
],
),
),
@ -309,14 +342,20 @@ class _AiGatewayPageState extends State<AiGatewayPage> {
}
}
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<CodexIntegrationCard> createState() => _CodexIntegrationCardState();
}
class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
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) {

View File

@ -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<MobileShell> {
}
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

View File

@ -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<ModulesPage> createState() => _ModulesPageState();
}
class _ModulesPageState extends State<ModulesPage> {
ModulesTab _tab = ModulesTab.gateway;
class _ModulesPageState extends State<ModulesPage> {
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')),
),
],
),

View File

@ -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<DetailPanelData> onOpenDetail;
final SecretsTab? initialTab;
@override
State<SecretsPage> createState() => _SecretsPageState();
}
class _SecretsPageState extends State<SecretsPage> {
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<SecretsPage> {
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<SecretsPage> {
),
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<SecretsPage> {
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<SecretsPage> {
}
}
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')),
),
],
),

View File

@ -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<SettingsPage> createState() => _SettingsPageState();
@ -31,6 +37,8 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
_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<SettingsPage> {
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<SettingsPage> {
);
}
List<Widget> _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<Widget> _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 => <Widget>[
_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 => <Widget>[
_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 => <Widget>[
_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 => <Widget>[
_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 => <Widget>[
_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 => <Widget>[
_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<Widget> _buildGeneral(
BuildContext context,
AppController controller,

View File

@ -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<SkillsPage> {
.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(

View File

@ -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<TasksPage> {
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(

View File

@ -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 {

View File

@ -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 <AppBreadcrumbItem>[],
this.eyebrow,
this.title,
this.subtitle,
@ -15,6 +17,7 @@ class DesktopWorkspaceScaffold extends StatelessWidget {
});
final Widget child;
final List<AppBreadcrumbItem> 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!,

View File

@ -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(<String, Object>{});
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(<String, Object>{});
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<void> _waitFor(bool Function() predicate) async {

View File

@ -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);
},
);
}

View File

@ -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);
});
}