diff --git a/client/lib/models/node.dart b/client/lib/models/node.dart index 95ff67d..5fd0035 100644 --- a/client/lib/models/node.dart +++ b/client/lib/models/node.dart @@ -16,6 +16,7 @@ class Node { this.tag = NodeTag.none, this.host = '', this.port = 0, + this.status = 'up', }); /// 服务端 UUID,用于 POST /v1/nodes/{uuid}/connect。 @@ -37,6 +38,12 @@ class Node { final String host; final int port; + /// 节点状态(来自 /v1/nodes):'up' 可用;其余(如 'down')= agent 离线/不可用。 + final String status; + + /// 不可用:status 非 'up'(节点 agent 离线,连了也会 503)。 + bool get isDown => status != 'up'; + /// 延迟标签:测得显示「Nms」,未测显示「—」。 String get pingLabel => ping > 0 ? '${ping}ms' : '—'; @@ -50,6 +57,7 @@ class Node { tag: tag, host: host, port: port, + status: status, ); String localizedName(AppLang lang) => lang == AppLang.zh ? nameZh : nameEn; diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index 3c9b060..7868536 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -158,38 +158,48 @@ class _NodeGridTile extends StatelessWidget { @override Widget build(BuildContext context) { final c = context.pangolin; - return Material( - color: active ? c.accentSubtle : c.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(PangolinRadius.lg), - side: BorderSide(color: active ? c.accentBorder : c.border, width: 1.5), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11), - child: Row(children: [ - CountryCode(code: node.code, active: active), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(node.localizedName(lang), - overflow: TextOverflow.ellipsis, - style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)), - Text(node.localizedSub(lang), - overflow: TextOverflow.ellipsis, - style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - ]), - ), - const SizedBox(width: 8), - Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)), - const SizedBox(height: 5), - SignalBars(ping: node.ping), + final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。 + final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable'; + return Opacity( + opacity: down ? 0.55 : 1.0, + child: Material( + color: (active && !down) ? c.accentSubtle : c.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(PangolinRadius.lg), + side: BorderSide(color: (active && !down) ? c.accentBorder : c.border, width: 1.5), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: down ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11), + child: Row(children: [ + CountryCode(code: node.code, active: active && !down), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(node.localizedName(lang), + overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)), + Text(node.localizedSub(lang), + overflow: TextOverflow.ellipsis, + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + ]), + ), + const SizedBox(width: 8), + if (down) + Text(unavail, + style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w600, fontSize: 12)) + else ...[ + Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)), + const SizedBox(height: 5), + SignalBars(ping: node.ping), + ]), + if (active) ...[const SizedBox(width: 8), Icon(PangolinIcons.check, size: 18, color: c.accent)], + ], ]), - if (active) ...[const SizedBox(width: 8), Icon(PangolinIcons.check, size: 18, color: c.accent)], - ]), + ), ), ), ); diff --git a/client/lib/state/nodes_provider.dart b/client/lib/state/nodes_provider.dart index 75a243f..cc1e614 100644 --- a/client/lib/state/nodes_provider.dart +++ b/client/lib/state/nodes_provider.dart @@ -73,6 +73,7 @@ class NodesNotifier extends AsyncNotifier> { tier: m['tier'] as String? ?? 'free', host: m['host'] as String? ?? '', port: (m['port'] as num?)?.toInt() ?? 0, + status: m['status'] as String? ?? 'up', ping: 0, // 由 _measure 实测回填 ); }).toList(); diff --git a/client/lib/widgets/server_tile.dart b/client/lib/widgets/server_tile.dart index 72999b8..e36a3cc 100644 --- a/client/lib/widgets/server_tile.dart +++ b/client/lib/widgets/server_tile.dart @@ -43,33 +43,43 @@ class ServerTile extends StatelessWidget { @override Widget build(BuildContext context) { final c = context.pangolin; + final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。 + final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable'; return InkWell( - onTap: onTap, - child: Container( - color: active ? c.accentSubtle : Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - CountryCode(code: node.code, active: active), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(node.localizedName(lang), - style: PangolinText.sm - .copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 15)), - const SizedBox(height: 2), - Text(node.localizedSub(lang), - style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - ], + onTap: down ? null : onTap, + child: Opacity( + opacity: down ? 0.55 : 1.0, + child: Container( + color: (active && !down) ? c.accentSubtle : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + CountryCode(code: node.code, active: active && !down), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(node.localizedName(lang), + style: PangolinText.sm + .copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 15)), + const SizedBox(height: 2), + Text(node.localizedSub(lang), + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + ], + ), ), - ), - Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)), - const SizedBox(width: 8), - SignalBars(ping: node.ping), - if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)], - ], + if (down) + Text(unavail, + style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w600, fontSize: 12)) + else ...[ + Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)), + const SizedBox(width: 8), + SignalBars(ping: node.ping), + if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)], + ], + ], + ), ), ), ); diff --git a/client/test/widget/server_tile_down_test.dart b/client/test/widget/server_tile_down_test.dart new file mode 100644 index 0000000..f878beb --- /dev/null +++ b/client/test/widget/server_tile_down_test.dart @@ -0,0 +1,43 @@ +// server_tile_down_test.dart — 节点不可用(status!=up)的列表项渲染断言。 +// 锁住 #7 客户端侧:agent 离线 → 节点显示「不可用」+ 禁选(onTap 不触发)。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/models/node.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/widgets/server_tile.dart'; + +import '../helpers/harness.dart'; + +void main() { + setUpAll(disableGoogleFontsFetching); + + Widget wrap(Node node, VoidCallback onTap) => MaterialApp( + theme: PangolinTheme.light, + home: Scaffold(body: ServerTile(node: node, lang: AppLang.zh, onTap: onTap)), + ); + + testWidgets('节点 down → 显示「不可用」且点击不触发', (t) async { + var tapped = false; + await t.pumpWidget(wrap( + const Node(code: 'US', nameZh: '洛杉矶', nameEn: 'LA', ping: 30, status: 'down'), + () => tapped = true, + )); + expect(find.text('不可用'), findsOneWidget); + await t.tap(find.byType(ServerTile)); + await t.pump(); + expect(tapped, isFalse, reason: 'down 节点禁选,onTap 不触发'); + }); + + testWidgets('节点 up → 不显示「不可用」,点击正常触发', (t) async { + var tapped = false; + await t.pumpWidget(wrap( + const Node(code: 'US', nameZh: '洛杉矶', nameEn: 'LA', ping: 30), // status 默认 up + () => tapped = true, + )); + expect(find.text('不可用'), findsNothing); + await t.tap(find.byType(ServerTile)); + await t.pump(); + expect(tapped, isTrue); + }); +}