feat(client): P4 节点实测延迟/设备/套餐价/兑换/统计接真(#6 6D)
- latency_probe.dart:并行 TCP 握手实测各节点 per-client RTT(服务端无法代知)。 - nodes_provider:解析 /v1/nodes 的 host/port,后台测延迟回填 Node.ping;智能选择 按实测最小;去掉 8 个伪造演示节点(空列表用中性占位,不再伪造服务器)。 - Node:+host/port/copyWith/pingLabel(未测显示 —);各端 ping 显示改 pingLabel。 - account_screens:PlansScreen 接 /v1/plans(价格 priceLabel + me 标当前档); DevicesScreen 接 /v1/me/devices + 移除 DELETE + last_seen;RedeemScreen 接 /v1/redeem(成功刷新 me,失败显示后端文案)。 - stats_page:周柱接 me.weekly_gb,本月流量/时长接 usage(30),延迟用生效节点实测。 - 顺带补登记早前 log_time.dart 的删除(log.dart 重构遗漏 stage)。 flutter analyze 0 error;115 tests passed;受影响 4 golden 重生成。 本机设备高亮 + 真实 device id 随 P6(device_info_plus)落地。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,65 @@
|
||||
// nodes_provider.dart — 节点清单 + 当前选择
|
||||
// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟
|
||||
//
|
||||
// 从 GET /v1/nodes 拉取节点列表;认证后自动刷新。
|
||||
// 未登录 / 加载中时退回演示节点(kDemoNodes)保证 UI 正常显示。
|
||||
// 从 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 '../models/node.dart';
|
||||
import '../services/api_config.dart';
|
||||
import '../services/latency_probe.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
// ── API base URL(由 --dart-define 注入)──────────────────────────
|
||||
|
||||
const _kApiUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
|
||||
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
|
||||
|
||||
class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
Future<List<Node>> build() async {
|
||||
ref.onDispose(() => _disposed = true);
|
||||
final auth = ref.watch(authProvider);
|
||||
if (!auth.isLoggedIn) return kDemoNodes;
|
||||
return _fetchNodes(auth.accessToken!);
|
||||
if (!auth.isLoggedIn) return const [];
|
||||
final list = await _fetchNodes(auth.accessToken!);
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
final auth = ref.read(authProvider);
|
||||
if (!auth.isLoggedIn) {
|
||||
state = AsyncData(kDemoNodes);
|
||||
state = const AsyncData([]);
|
||||
return;
|
||||
}
|
||||
state = await AsyncValue.guard(() => _fetchNodes(auth.accessToken!));
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final list = await _fetchNodes(auth.accessToken!);
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。
|
||||
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] ?? n.ping)];
|
||||
state = AsyncData(updated);
|
||||
}
|
||||
|
||||
static Future<List<Node>> _fetchNodes(String accessToken) async {
|
||||
final uri = Uri.parse('$_kApiUrl/v1/nodes');
|
||||
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 kDemoNodes;
|
||||
if (resp.statusCode != 200) return const [];
|
||||
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final rawList = body['nodes'] as List<dynamic>? ?? [];
|
||||
@@ -55,7 +71,9 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
nameZh: m['name_zh'] as String? ?? '',
|
||||
nameEn: m['name_en'] as String? ?? '',
|
||||
tier: m['tier'] as String? ?? 'free',
|
||||
ping: 0, // 延迟由探针数据填充;MVP 默认 0
|
||||
host: m['host'] as String? ?? '',
|
||||
port: (m['port'] as num?)?.toInt() ?? 0,
|
||||
ping: 0, // 由 _measure 实测回填
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@@ -64,7 +82,7 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
final nodesProvider =
|
||||
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
|
||||
|
||||
// ── 当前选中的节点 UUID;'AUTO' 表示智能选择(默认)─────────────────
|
||||
// ── 当前选中的节点 code;'AUTO' 表示智能选择(默认)─────────────────
|
||||
|
||||
final selectedNodeCodeProvider = StateProvider<String>((ref) => kSmartNodeCode);
|
||||
|
||||
@@ -73,16 +91,18 @@ final isSmartSelectProvider = Provider<bool>(
|
||||
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
|
||||
);
|
||||
|
||||
/// 实际生效的节点:同步拉取 AsyncValue;未就绪时取 kDemoNodes 第一条。
|
||||
/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。
|
||||
final effectiveNodeProvider = Provider<Node>((ref) {
|
||||
final nodesAsync = ref.watch(nodesProvider);
|
||||
final nodes = nodesAsync.valueOrNull ?? kDemoNodes;
|
||||
if (nodes.isEmpty) return kDemoNodes.first;
|
||||
final nodes = ref.watch(nodesProvider).valueOrNull ?? const <Node>[];
|
||||
if (nodes.isEmpty) return kPlaceholderNode;
|
||||
|
||||
final code = ref.watch(selectedNodeCodeProvider);
|
||||
if (code == kSmartNodeCode) {
|
||||
// 延迟最小者;MVP 无真实延迟时取第一条
|
||||
return nodes.reduce((a, b) => a.ping <= b.ping ? a : b);
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user