Files
pangolin/client/lib/state/nodes_provider.dart
T
wangjia d08b908e1f fix(client): 节点列表延迟探测失败时保留旧值,修连接后整列延迟消失
返工引入的回归:看门狗连接态周期 refresh→_measure 重测所有节点 TCP ping,但连接态
全局 TUN 把到节点的握手接住→probeAll 返回 0→pings[uuid]??n.ping 因 0 非 null 覆盖成空。
改:探测返回 0 时保留旧 ping。连接态保留上次延迟,断开态正常刷新。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:26:19 +08:00

162 lines
5.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟
//
// 从 GET /v1/nodes 拉取真实节点(含 host/port);拉取后后台对各节点做 TCP 握手
// 实测 per-client 延迟并回填。未登录 / 失败返回空列表(不再伪造演示节点)。
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/node.dart';
import '../services/api_config.dart';
import '../services/latency_probe.dart';
import 'auth_provider.dart';
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
class NodesNotifier extends AsyncNotifier<List<Node>> {
bool _disposed = false;
DateTime? _lastFetch;
/// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。
static const _minInterval = Duration(seconds: 10);
@override
Future<List<Node>> build() async {
ref.onDispose(() => _disposed = true);
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;
}
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 未销毁才更新)。
/// 探测失败(返回 0,如连接态全局 TUN 把到节点的 TCP 握手接住)时**保留旧 ping**,
/// 不把已知延迟覆盖成空——否则连上后节点列表延迟会整列消失。
Future<void> _measure(List<Node> list) async {
if (list.isEmpty) return;
final pings = await probeAll(
[for (final n in list) (uuid: n.uuid, host: n.host, port: n.port)],
);
if (_disposed) return;
final updated = [
for (final n in list)
n.copyWith(ping: (pings[n.uuid] ?? 0) > 0 ? pings[n.uuid]! : n.ping)
];
state = AsyncData(updated);
}
static Future<List<Node>> _fetchNodes(String accessToken) async {
final uri = Uri.parse('$kApiBaseUrl/v1/nodes');
final resp = await http.get(uri, headers: {
'Authorization': 'Bearer $accessToken',
}).timeout(const Duration(seconds: 10));
if (resp.statusCode != 200) return const [];
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final rawList = body['nodes'] as List<dynamic>? ?? [];
return rawList.map((e) {
final m = e as Map<String, dynamic>;
return Node(
uuid: m['id'] as String? ?? '',
code: m['region'] as String? ?? '??',
nameZh: m['name_zh'] as String? ?? '',
nameEn: m['name_en'] as String? ?? '',
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();
}
}
final nodesProvider =
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
// ── 当前选中的节点 code'AUTO' 表示智能选择(默认)─────────────────
//
// 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。
const _kSelectedNode = 'selected_node_code';
class SelectedNodeController extends StateNotifier<String> {
SelectedNodeController() : super(kSmartNodeCode) {
_load();
}
/// 是否已从持久化恢复完(自动连接要等它 true 再读,避免用到未恢复的默认值)。
bool loaded = false;
Future<void> _load() async {
try {
final p = await SharedPreferences.getInstance();
if (!mounted) return;
final v = p.getString(_kSelectedNode);
if (v != null && v.isNotEmpty) state = v;
} catch (_) {
// 测试/无平台:保留默认(智能)。
}
loaded = true;
}
/// 选择节点(智能传 kSmartNodeCode)并持久化。
Future<void> select(String code) async {
state = code;
try {
final p = await SharedPreferences.getInstance();
await p.setString(_kSelectedNode, code);
} catch (_) {}
}
}
final selectedNodeCodeProvider =
StateNotifierProvider<SelectedNodeController, String>((ref) => SelectedNodeController());
/// 是否处于智能选择。
final isSmartSelectProvider = Provider<bool>(
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
);
/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。
final effectiveNodeProvider = Provider<Node>((ref) {
final nodes = ref.watch(nodesProvider).valueOrNull ?? const <Node>[];
if (nodes.isEmpty) return kPlaceholderNode;
final code = ref.watch(selectedNodeCodeProvider);
if (code == kSmartNodeCode) {
return nodes.reduce((a, b) {
final pa = a.ping > 0 ? a.ping : 1 << 30;
final pb = b.ping > 0 ? b.ping : 1 << 30;
return pa <= pb ? a : b;
});
}
return nodes.firstWhere((n) => n.code == code, orElse: () => nodes.first);
});