diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index 7868536..23a5aee 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -25,6 +25,23 @@ class NodesPage extends ConsumerStatefulWidget { class _NodesPageState extends ConsumerState { String _q = ''; + bool _refreshing = false; + + @override + void initState() { + super.initState(); + // 切到节点页自动刷新一次(受 nodesProvider 的 10s 限流)。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refresh(); + }); + } + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + await ref.read(nodesProvider.notifier).refresh(); + if (mounted) setState(() => _refreshing = false); + } void _pick(String code) { ref.read(selectedNodeCodeProvider.notifier).state = code; @@ -49,18 +66,27 @@ class _NodesPageState extends ConsumerState { final smart = selected == kSmartNodeCode; final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode)); - final search = _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v)); + // 搜索框 + 刷新按钮(点击刷新);列表本身支持下拉刷新。 + final searchRow = Row(children: [ + Expanded(child: _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v))), + const SizedBox(width: 10), + _RefreshButton(refreshing: _refreshing, onTap: _refresh), + ]); if (widget.isWide) { // 对照 dapp.jsx DServers:padding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。 - return ListView( - padding: const EdgeInsets.fromLTRB(28, 4, 28, 24), - children: [ - smartCard, - const SizedBox(height: 14), - ConstrainedBox(constraints: const BoxConstraints(maxWidth: 380), child: search), - const SizedBox(height: 14), - GridView.count( + return RefreshIndicator( + color: c.accent, + backgroundColor: c.surface, + onRefresh: _refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(28, 4, 28, 24), + children: [ + smartCard, + const SizedBox(height: 14), + ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow), + const SizedBox(height: 14), + GridView.count( crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -73,6 +99,7 @@ class _NodesPageState extends ConsumerState { ], ), ], + ), ); } @@ -81,13 +108,17 @@ class _NodesPageState extends ConsumerState { AppTopBar(brand: t.brand), PageTitle(title: t.chooseNode), Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - children: [ - smartCard, - const SizedBox(height: 12), - search, - const SizedBox(height: 12), + child: RefreshIndicator( + color: c.accent, + backgroundColor: c.surface, + onRefresh: _refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + children: [ + smartCard, + const SizedBox(height: 12), + searchRow, + const SizedBox(height: 12), Container( decoration: BoxDecoration( color: c.surface, @@ -109,6 +140,7 @@ class _NodesPageState extends ConsumerState { ]), ), ], + ), ), ), ]); @@ -148,6 +180,35 @@ class _SearchBox extends StatelessWidget { } /// 宽屏双列网格中的节点块。 +/// 刷新按钮(搜索框右侧):点击刷新节点列表(受 10s 限流);刷新中显示 spinner。 +class _RefreshButton extends StatelessWidget { + const _RefreshButton({required this.refreshing, required this.onTap}); + final bool refreshing; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Material( + color: c.bgSubtle, + borderRadius: BorderRadius.circular(PangolinRadius.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: refreshing ? null : onTap, + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: refreshing + ? SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: c.accent)) + : Icon(PangolinIcons.refreshCw, size: 18, color: c.fg2), + ), + ), + ), + ); + } +} + class _NodeGridTile extends StatelessWidget { const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap}); final Node node; diff --git a/client/lib/state/nodes_provider.dart b/client/lib/state/nodes_provider.dart index cc1e614..fb5933b 100644 --- a/client/lib/state/nodes_provider.dart +++ b/client/lib/state/nodes_provider.dart @@ -17,6 +17,10 @@ import 'auth_provider.dart'; class NodesNotifier extends AsyncNotifier> { bool _disposed = false; + DateTime? _lastFetch; + + /// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。 + static const _minInterval = Duration(seconds: 10); @override Future> build() async { @@ -24,22 +28,30 @@ class NodesNotifier extends AsyncNotifier> { final auth = ref.watch(authProvider); if (!auth.isLoggedIn) return const []; final list = await _fetchNodes(auth.accessToken!); + _lastFetch = DateTime.now(); unawaited(_measure(list)); return list; } + /// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换), + /// 避免进页/下拉时列表闪空;失败保留旧数据。 Future refresh() async { final auth = ref.read(authProvider); if (!auth.isLoggedIn) { state = const AsyncData([]); return; } - state = const AsyncLoading(); - state = await AsyncValue.guard(() async { + if (_lastFetch != null && DateTime.now().difference(_lastFetch!) < _minInterval) { + return; // 限流:10s 内不重复请求远端 + } + final result = await AsyncValue.guard(() async { final list = await _fetchNodes(auth.accessToken!); + _lastFetch = DateTime.now(); unawaited(_measure(list)); return list; }); + if (_disposed) return; + if (result.hasValue) state = result; // 成功才换;失败保留旧列表 } /// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。 diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png index ebd8b05..f5a53c7 100644 Binary files a/client/test/golden/goldens/desktop_servers.png and b/client/test/golden/goldens/desktop_servers.png differ diff --git a/client/test/golden/goldens/tablet_servers_dark_zh.png b/client/test/golden/goldens/tablet_servers_dark_zh.png index 2eff404..afea824 100644 Binary files a/client/test/golden/goldens/tablet_servers_dark_zh.png and b/client/test/golden/goldens/tablet_servers_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_en.png b/client/test/golden/goldens/tablet_servers_light_en.png index 0aeaae8..59644e6 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_en.png and b/client/test/golden/goldens/tablet_servers_light_en.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_zh.png b/client/test/golden/goldens/tablet_servers_light_zh.png index fc79a34..ebf86b0 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_zh.png and b/client/test/golden/goldens/tablet_servers_light_zh.png differ