feat(client/nodes): 节点页刷新 — 下拉刷新 + 刷新按钮 + 进页刷新 + 10s 限流
- nodesProvider.refresh:10s 限流(下拉/按钮/进页多次触发只打一次远端); 不清空当前列表(后台拉、好了再换,失败保留旧数据),避免进页/下拉闪空 - nodes_page:initState 进页自动刷一次;RefreshIndicator 下拉刷新(移动端); 搜索框右侧 _RefreshButton 点击刷新(刷新中 spinner) servers 页 golden 重生成(搜索行加按钮);CI 闸 golden 未动。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -25,6 +25,23 @@ class NodesPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
String _q = '';
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 切到节点页自动刷新一次(受 nodesProvider 的 10s 限流)。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _refresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<NodesPage> {
|
||||
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<NodesPage> {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,13 +108,17 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
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<NodesPage> {
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,10 @@ import 'auth_provider.dart';
|
||||
|
||||
class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
bool _disposed = false;
|
||||
DateTime? _lastFetch;
|
||||
|
||||
/// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。
|
||||
static const _minInterval = Duration(seconds: 10);
|
||||
|
||||
@override
|
||||
Future<List<Node>> build() async {
|
||||
@@ -24,22 +28,30 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
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<void> 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 未销毁才更新)。
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |