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> {
|
class _NodesPageState extends ConsumerState<NodesPage> {
|
||||||
String _q = '';
|
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) {
|
void _pick(String code) {
|
||||||
ref.read(selectedNodeCodeProvider.notifier).state = code;
|
ref.read(selectedNodeCodeProvider.notifier).state = code;
|
||||||
@@ -49,18 +66,27 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
|||||||
final smart = selected == kSmartNodeCode;
|
final smart = selected == kSmartNodeCode;
|
||||||
|
|
||||||
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(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) {
|
if (widget.isWide) {
|
||||||
// 对照 dapp.jsx DServers:padding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。
|
// 对照 dapp.jsx DServers:padding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。
|
||||||
return ListView(
|
return RefreshIndicator(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 4, 28, 24),
|
color: c.accent,
|
||||||
children: [
|
backgroundColor: c.surface,
|
||||||
smartCard,
|
onRefresh: _refresh,
|
||||||
const SizedBox(height: 14),
|
child: ListView(
|
||||||
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 380), child: search),
|
padding: const EdgeInsets.fromLTRB(28, 4, 28, 24),
|
||||||
const SizedBox(height: 14),
|
children: [
|
||||||
GridView.count(
|
smartCard,
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
GridView.count(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
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),
|
AppTopBar(brand: t.brand),
|
||||||
PageTitle(title: t.chooseNode),
|
PageTitle(title: t.chooseNode),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: RefreshIndicator(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
color: c.accent,
|
||||||
children: [
|
backgroundColor: c.surface,
|
||||||
smartCard,
|
onRefresh: _refresh,
|
||||||
const SizedBox(height: 12),
|
child: ListView(
|
||||||
search,
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
const SizedBox(height: 12),
|
children: [
|
||||||
|
smartCard,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
searchRow,
|
||||||
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: c.surface,
|
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 {
|
class _NodeGridTile extends StatelessWidget {
|
||||||
const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap});
|
const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap});
|
||||||
final Node node;
|
final Node node;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import 'auth_provider.dart';
|
|||||||
|
|
||||||
class NodesNotifier extends AsyncNotifier<List<Node>> {
|
class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
DateTime? _lastFetch;
|
||||||
|
|
||||||
|
/// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。
|
||||||
|
static const _minInterval = Duration(seconds: 10);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Node>> build() async {
|
Future<List<Node>> build() async {
|
||||||
@@ -24,22 +28,30 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
|||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
if (!auth.isLoggedIn) return const [];
|
if (!auth.isLoggedIn) return const [];
|
||||||
final list = await _fetchNodes(auth.accessToken!);
|
final list = await _fetchNodes(auth.accessToken!);
|
||||||
|
_lastFetch = DateTime.now();
|
||||||
unawaited(_measure(list));
|
unawaited(_measure(list));
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换),
|
||||||
|
/// 避免进页/下拉时列表闪空;失败保留旧数据。
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final auth = ref.read(authProvider);
|
final auth = ref.read(authProvider);
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
state = const AsyncData([]);
|
state = const AsyncData([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = const AsyncLoading();
|
if (_lastFetch != null && DateTime.now().difference(_lastFetch!) < _minInterval) {
|
||||||
state = await AsyncValue.guard(() async {
|
return; // 限流:10s 内不重复请求远端
|
||||||
|
}
|
||||||
|
final result = await AsyncValue.guard(() async {
|
||||||
final list = await _fetchNodes(auth.accessToken!);
|
final list = await _fetchNodes(auth.accessToken!);
|
||||||
|
_lastFetch = DateTime.now();
|
||||||
unawaited(_measure(list));
|
unawaited(_measure(list));
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
if (_disposed) return;
|
||||||
|
if (result.hasValue) state = result; // 成功才换;失败保留旧列表
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。
|
/// 后台实测各节点延迟,完成后回填(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 |