diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index 587ce1d..d53fcf2 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -7,15 +7,37 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/responsive/form_factor.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; import '../services/web_launch.dart'; import '../state/app_providers.dart'; +import '../state/navigation_provider.dart'; +import '../state/routing_provider.dart'; import '../state/settings_provider.dart'; import '../state/update_provider.dart'; import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; +import '../widgets/routing_screen.dart'; + +/// 「分流规则」当前模式短标签(与 [RoutingScreen] 段选顺序一致的三态)。 +String _routingModeLabel(AppText t, String mode) => switch (mode) { + 'global' => t.routingModeGlobal, + 'direct' => t.routingModeDirect, + _ => t.routingModeSmart, // 'rule' + 兜底 + }; + +/// 打开「分流规则」子屏:desktop/tablet 在内容区下钻(切 navViewProvider,顶栏带返回到 +/// settings);mobile 全屏 push(RoutingScreen 自带返回箭头,同 account_page.dart 的 +/// mobile 全屏 push 范式)。 +void _openRouting(BuildContext context, WidgetRef ref) { + if (context.formFactor == FormFactor.mobile) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RoutingScreen())); + } else { + ref.read(navViewProvider.notifier).state = NavView.routing; + } +} /// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示, /// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。 @@ -63,7 +85,15 @@ class SettingsPage extends ConsumerWidget { if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) _Row(title: t.autostart, sub: t.autostartSub, right: sw(s.autostart, settings.setAutostart)), _Row(title: t.autoConnect, sub: t.autoConnectSub, right: sw(s.autoConnect, settings.setAutoConnect)), - _Row(title: t.smartRoute, sub: t.smartRouteSub, right: sw(s.smartRoute, settings.setSmartRoute)), + _Row( + title: t.routingRulesTitle, + right: Row(mainAxisSize: MainAxisSize.min, children: [ + _ModePill(label: _routingModeLabel(t, ref.watch(routingProfileProvider).valueOrNull?.mode ?? 'rule')), + const SizedBox(width: 4), + Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), + ]), + onTap: () => _openRouting(context, ref), + ), _Row(title: t.killSwitch, sub: t.killSwitchSub, last: true, right: sw(s.killSwitch, settings.setKillSwitch)), ]), const SizedBox(height: 20), @@ -157,6 +187,22 @@ class _Row extends StatelessWidget { } } +/// 「分流规则」行右侧的当前模式小药丸(全局代理/智能分流/全部直连)。 +class _ModePill extends StatelessWidget { + const _ModePill({required this.label}); + final String label; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), + child: Text(label, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 11)), + ); + } +} + class _LangSwitch extends StatelessWidget { const _LangSwitch({required this.lang, required this.onPick}); final AppLang lang; diff --git a/client/lib/shell/desktop_shell.dart b/client/lib/shell/desktop_shell.dart index 7c6370d..585fb37 100644 --- a/client/lib/shell/desktop_shell.dart +++ b/client/lib/shell/desktop_shell.dart @@ -22,6 +22,7 @@ import '../widgets/account_screens.dart'; import '../widgets/content_top_bar.dart'; import '../widgets/nav_sidebar.dart'; import '../widgets/pangolin_icons.dart'; +import '../widgets/routing_screen.dart'; class DesktopShell extends ConsumerWidget { const DesktopShell({super.key}); @@ -56,6 +57,7 @@ class DesktopShell extends ConsumerWidget { NavView.orders: t.ordersTitle, NavView.invite: t.inviteTitle, NavView.notifications: t.notifTitle, + NavView.routing: t.routingRulesTitle, }; void go(NavView v) => ref.read(navViewProvider.notifier).state = v; @@ -89,14 +91,18 @@ class DesktopShell extends ConsumerWidget { return InviteScreen(t: t, embedded: true); case NavView.notifications: return NotificationsScreen(t: t, embedded: true); + // 设置下钻子页(内容区,顶栏带返回到 settings) + case NavView.routing: + return const RoutingScreen(embedded: true); } } // 子页返回目标:payment → purchase(换方式/失败重试仍在购买语境内); - // 其余下钻子页(含 purchase 本身)→ account。 + // 设置下钻子页 → settings;其余下钻子页(含 purchase 本身)→ account。 VoidCallback? backTarget() { if (view == NavView.payment) return () => go(NavView.purchase); if (view == NavView.notifications) return () => go(NavView.account); + if (kSettingsSubViews.contains(view)) return () => go(NavView.settings); if (kAccountSubViews.contains(view)) return () => go(NavView.account); return null; } diff --git a/client/lib/shell/tablet_shell.dart b/client/lib/shell/tablet_shell.dart index 522e1b2..04f21cf 100644 --- a/client/lib/shell/tablet_shell.dart +++ b/client/lib/shell/tablet_shell.dart @@ -18,6 +18,7 @@ import '../state/navigation_provider.dart'; import '../widgets/content_top_bar.dart'; import '../widgets/nav_sidebar.dart'; import '../widgets/pangolin_icons.dart'; +import '../widgets/routing_screen.dart'; class TabletShell extends ConsumerWidget { const TabletShell({super.key}); @@ -46,6 +47,7 @@ class TabletShell extends ConsumerWidget { NavView.contact: t.contactTitle, NavView.invite: t.inviteTitle, NavView.notifications: t.notifTitle, + NavView.routing: t.routingRulesTitle, }; void go(NavView v) => ref.read(navViewProvider.notifier).state = v; @@ -64,14 +66,17 @@ class TabletShell extends ConsumerWidget { return const ContactPage(); case NavView.notifications: return NotificationsScreen(t: t, embedded: true); + case NavView.routing: + return const RoutingScreen(embedded: true); default: // account / plans / redeem / devices → 账户(其余下钻在账户页内) return const AccountPage(isWide: true); } } - // 通知子页返回目标:回账户(镜像桌面 desktop_shell.dart::backTarget)。 + // 通知子页返回目标:回账户;设置下钻子页 → settings(镜像桌面 desktop_shell.dart::backTarget)。 VoidCallback? backTarget() { if (view == NavView.notifications) return () => go(NavView.account); + if (kSettingsSubViews.contains(view)) return () => go(NavView.settings); return null; } diff --git a/client/lib/state/navigation_provider.dart b/client/lib/state/navigation_provider.dart index cd9ba5e..6cf46f2 100644 --- a/client/lib/state/navigation_provider.dart +++ b/client/lib/state/navigation_provider.dart @@ -1,14 +1,18 @@ // navigation_provider.dart — 主导航当前视图(三端共享) import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// 一级视图 + 账户子页。 +/// 一级视图 + 账户子页 + 设置子页。 /// desktop 侧栏暴露 6 个一级项(connect..settings);mobile/tablet 取前 4 项, -/// contact/settings 在 mobile 走账户子页。redeem 是 account 的下钻页。 -enum NavView { connect, servers, stats, account, contact, settings, redeem, devices, purchase, payment, orders, invite, notifications } +/// contact/settings 在 mobile 走账户子页。redeem 是 account 的下钻页;routing 是 +/// settings 的下钻页(分流规则)。 +enum NavView { connect, servers, stats, account, contact, settings, redeem, devices, purchase, payment, orders, invite, notifications, routing } /// 账户下钻子页(desktop 在内容区切换、顶栏带返回;mobile/tablet 走全屏 push)。 const Set kAccountSubViews = {NavView.redeem, NavView.devices, NavView.purchase, NavView.payment, NavView.orders, NavView.invite}; +/// 设置下钻子页(desktop/tablet 在内容区切换、顶栏带返回到 settings;mobile 走全屏 push)。 +const Set kSettingsSubViews = {NavView.routing}; + /// 当前视图。 final navViewProvider = StateProvider((ref) => NavView.connect); diff --git a/client/test/golden/goldens/desktop_settings.png b/client/test/golden/goldens/desktop_settings.png index b940ebf..0859312 100644 Binary files a/client/test/golden/goldens/desktop_settings.png and b/client/test/golden/goldens/desktop_settings.png differ diff --git a/client/test/widget/settings_ia_test.dart b/client/test/widget/settings_ia_test.dart index 0938f28..4fca0d6 100644 --- a/client/test/widget/settings_ia_test.dart +++ b/client/test/widget/settings_ia_test.dart @@ -10,6 +10,7 @@ import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; import 'package:pangolin_vpn/l10n/app_text.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/models/routing_profile.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/settings_page.dart'; import 'package:pangolin_vpn/services/token_store.dart'; @@ -18,6 +19,8 @@ import 'package:pangolin_vpn/shell/home_shell.dart'; import 'package:pangolin_vpn/state/app_providers.dart'; import 'package:pangolin_vpn/state/auth_provider.dart'; import 'package:pangolin_vpn/state/navigation_provider.dart'; +import 'package:pangolin_vpn/state/routing_provider.dart'; +import 'package:pangolin_vpn/widgets/routing_screen.dart'; import '../helpers/harness.dart'; @@ -82,4 +85,52 @@ void main() { debugDefaultTargetPlatformOverride = null; } }); + + testWidgets('settings 连接组:smartRoute 开关被「分流规则」下钻行取代(标题+当前模式pill+chevron),点击导航到 RoutingScreen', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.view.physicalSize = const Size(920, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + appTextProvider.overrideWithValue(t), + navViewProvider.overrideWith((ref) => NavView.settings), + routingProfileProvider.overrideWith(() => _FakeRoutingNotifier(const RoutingProfile(mode: 'global'))), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const HomeShell(), + ), + )); + await tester.pumpAndSettle(); + + // 旧的 smartRoute 布尔开关行已消失。 + expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.smartRoute)), findsNothing); + + // 新的下钻行:标题「分流规则」+ 当前模式(全局代理)pill。 + final routingRow = find.descendant(of: find.byType(SettingsPage), matching: find.text(t.routingRulesTitle)); + expect(routingRow, findsOneWidget); + expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.routingModeGlobal)), findsOneWidget); + + await tester.tap(routingRow); + await tester.pumpAndSettle(); + + expect(find.byType(RoutingScreen), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} + +class _FakeRoutingNotifier extends RoutingProfileNotifier { + _FakeRoutingNotifier(this._v); + final RoutingProfile _v; + @override + Future build() async => _v; }