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>
This commit is contained in:
wangjia
2026-06-28 22:00:26 +08:00
parent 51c171d815
commit 0542ff027f
6 changed files with 91 additions and 18 deletions
+77 -16
View File
@@ -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 DServerspadding 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;