From 73da0bc9a2fd88c729e665c1e45100bcb09530d5 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 10:11:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=8A=82=E7=82=B9=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=98=B2=E8=AF=AF=E8=A7=A6=20=E2=80=94=20=E5=B7=B2?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=97=B6=E5=88=87=E6=8D=A2=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=85=88=E5=BC=B9=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未连接(off)点节点直接选→跳连接页(无破坏性,不弹框);已连接/连接中点其他 节点会断开重连,先弹确认框(标题含目标节点名),取消则维持当前连接。新增 nodeSwitchTitle/Body/Confirm 三条文案 + node_connect_confirm_test 覆盖 off/on 两路。 Co-Authored-By: Claude Opus 4.8 --- client/lib/l10n/app_text.dart | 4 + client/lib/l10n/strings_en.dart | 6 ++ client/lib/l10n/strings_zh.dart | 6 ++ client/lib/screens/nodes_page.dart | 41 ++++++- .../widget/node_connect_confirm_test.dart | 101 ++++++++++++++++++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 client/test/widget/node_connect_confirm_test.dart diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 57aecf9..4a78df1 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -52,6 +52,10 @@ abstract class AppText { String get chooseNode; String get searchPh; String get smartSelect; + // 已连接时点其他节点 → 切换确认(防误触) + String nodeSwitchTitle(String name); + String get nodeSwitchBody; + String get nodeSwitchConfirm; String get smartSub; String get recommended; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 72f27e8..91a0f90 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -64,6 +64,12 @@ class StringsEn extends AppText { @override String get smartSelect => 'Smart select'; @override + String nodeSwitchTitle(String name) => 'Switch to $name?'; + @override + String get nodeSwitchBody => 'This disconnects the current link and reconnects to this node.'; + @override + String get nodeSwitchConfirm => 'Switch'; + @override String get smartSub => 'Picks the best node for your network'; @override String get recommended => 'Recommended'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 2d8a40e..b8008ed 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -63,6 +63,12 @@ class StringsZh extends AppText { @override String get smartSelect => '智能选择'; @override + String nodeSwitchTitle(String name) => '切换到「$name」?'; + @override + String get nodeSwitchBody => '当前连接将断开并重连到该节点。'; + @override + String get nodeSwitchConfirm => '切换'; + @override String get smartSub => '根据当前网络环境,自动选择最优节点'; @override String get recommended => '推荐'; diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index f93e3fb..8f5013c 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -49,6 +49,43 @@ class _NodesPageState extends ConsumerState { widget.onPicked(); } + // 点节点:未连接(off)直接选→跳连接页,无破坏性;已连接/连接中切换会断开重连, + // 弹确认防误触(误碰节点把当前连接切走)。 + void _onTapNode(Node n) { + final phase = ref.read(connectionProvider).phase; + if (phase == VpnPhase.off) { + _pick(n.code); + return; + } + _confirmSwitch(n); + } + + Future _confirmSwitch(Node n) async { + final c = context.pangolin; + final t = ref.read(appTextProvider); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), + title: Text(t.nodeSwitchTitle(n.localizedName(t.lang)), + style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + content: Text(t.nodeSwitchBody, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.nodeSwitchConfirm, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ), + ); + if (ok == true) _pick(n.code); + } + List _filtered(List nodes) { if (_q.trim().isEmpty) return nodes; final q = _q.toLowerCase(); @@ -95,7 +132,7 @@ class _NodesPageState extends ConsumerState { childAspectRatio: 4.4, children: [ for (final n in nodes) - _NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _pick(n.code)), + _NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _onTapNode(n)), ], ), ], @@ -133,7 +170,7 @@ class _NodesPageState extends ConsumerState { node: nodes[i], lang: t.lang, active: !smart && nodes[i].code == selected, - onTap: () => _pick(nodes[i].code), + onTap: () => _onTapNode(nodes[i]), ), if (i < nodes.length - 1) Divider(height: 1, thickness: 1, color: c.border), ], diff --git a/client/test/widget/node_connect_confirm_test.dart b/client/test/widget/node_connect_confirm_test.dart new file mode 100644 index 0000000..476094a --- /dev/null +++ b/client/test/widget/node_connect_confirm_test.dart @@ -0,0 +1,101 @@ +// node_connect_confirm_test.dart — 节点列表防误触:已连接时点其他节点先弹确认。 +// +// off → 点节点直接选(跳连接页,无破坏性,不弹确认)。 +// on → 点其他节点弹确认框,取消则不切换(防误碰把当前连接切走)。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/models/node.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/screens/nodes_page.dart'; +import 'package:pangolin_vpn/state/connection_provider.dart'; +import 'package:pangolin_vpn/state/nodes_provider.dart'; + +import '../helpers/harness.dart'; + +// 固定两节点的桩,绕过远端拉取与 10s 限流刷新。 +class _StubNodes extends NodesNotifier { + @override + Future> build() async => _nodes; + @override + Future refresh() async {} +} + +const _nodes = [ + Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 20, uuid: 'hk-uuid', host: 'hk', port: 443), + Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443), +]; + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [ + nodesProvider.overrideWith(_StubNodes.new), + vpnBridgeProvider.overrideWithValue(bridge), + ]); + + Future pump(WidgetTester tester, ProviderContainer container) async { + await tester.binding.setSurfaceSize(const Size(440, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: Scaffold(body: NodesPage(isWide: false, onPicked: () {})), + ), + )); + await tester.pumpAndSettle(); + } + + testWidgets('未连接:点节点直接选,不弹确认', (tester) async { + final container = makeContainer(VpnBridgeMock()); + addTearDown(container.dispose); + await pump(tester, container); + + expect(find.text('香港'), findsOneWidget); + await tester.tap(find.text('香港')); + await tester.pumpAndSettle(); + + expect(find.text(t.nodeSwitchConfirm), findsNothing, reason: 'off 态不应弹确认'); + expect(container.read(selectedNodeCodeProvider), 'HK', reason: '直接选中该节点'); + }); + + testWidgets('已连接:点其他节点弹确认 → 取消不切换', (tester) async { + final bridge = VpnBridgeMock(connectDelay: const Duration(milliseconds: 10)); + final container = makeContainer(bridge); + addTearDown(bridge.dispose); + addTearDown(container.dispose); + await pump(tester, container); + + // 创建控制器(订阅状态流)后驱动到 on;此后只用 pump(固定时长), + // 不能 pumpAndSettle(连接计时器每秒触发,永不 settle)。 + container.read(connectionProvider); + await bridge.start('{}'); + await tester.pump(const Duration(milliseconds: 15)); + expect(container.read(connectionProvider).phase, VpnPhase.on); + + await tester.tap(find.text('东京')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); // 弹窗入场 + + expect(find.text(t.nodeSwitchTitle('东京')), findsOneWidget, reason: 'on 态切换前先弹确认'); + expect(find.text(t.nodeSwitchConfirm), findsOneWidget); + expect(container.read(selectedNodeCodeProvider), kSmartNodeCode, reason: '弹窗期间还未切'); + + await tester.tap(find.text(t.devCancel)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); // 弹窗退场 + expect(container.read(selectedNodeCodeProvider), kSmartNodeCode, reason: '取消后维持原节点'); + + // 收尾:停桥 → 取消 stats 计时器 + 触发 off 回调取消 elapsed 计时器, + // 否则 fake-async 在用例结束时报「Pending timers」。 + await bridge.stop(); + await tester.pump(); + }); +}