fix(client): 补提交 stats-overhaul 漏掉的 account_api/kernel_process WIP
同 device_usage:account_api.dart(deviceUsage/usage 方法)、kernel_process.dart 及对应测试一直未提交,本地有→analyze/test 过,但不在 git→Windows 构建报 「deviceUsage isn't defined」。补齐使提交树自洽可构建。 孤儿组件 device_stat_row/metric_card 已无人引用,不提交。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,22 @@ class ClashApiClient {
|
|||||||
return data.cast<String, int>();
|
return data.cast<String, int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 工具:从 /proxies 响应提取 URLTest/Fallback 组名 ──────────
|
||||||
|
static List<String> extractUrltestGroups(Map<String, dynamic> proxiesResponse) {
|
||||||
|
final proxies = proxiesResponse['proxies'];
|
||||||
|
if (proxies is! Map) return const [];
|
||||||
|
final groups = <String>[];
|
||||||
|
for (final entry in proxies.entries) {
|
||||||
|
final p = entry.value;
|
||||||
|
if (p is! Map) continue;
|
||||||
|
final type = (p['type'] as String?) ?? '';
|
||||||
|
if (type == 'URLTest' || type == 'Fallback') {
|
||||||
|
if (entry.key is String) groups.add(entry.key as String);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 工具:从 /proxies 响应提取 URLTest 延迟列表 ──────────────
|
// ── 工具:从 /proxies 响应提取 URLTest 延迟列表 ──────────────
|
||||||
// 扫描所有类型为 URLTest 的出口组,提取成员节点的最新 history delay。
|
// 扫描所有类型为 URLTest 的出口组,提取成员节点的最新 history delay。
|
||||||
static List<UrltestResult> extractUrltestResults(
|
static List<UrltestResult> extractUrltestResults(
|
||||||
@@ -312,6 +328,12 @@ class DesktopKernelProcess implements KernelProcess {
|
|||||||
// 最新 URLTest 延迟列表(每次 stats 轮询时更新)
|
// 最新 URLTest 延迟列表(每次 stats 轮询时更新)
|
||||||
List<UrltestResult> _lastUrltestResults = const [];
|
List<UrltestResult> _lastUrltestResults = const [];
|
||||||
|
|
||||||
|
// URLTest/Fallback 组名(从 /proxies 提取),供主动触发延迟探测。
|
||||||
|
List<String> _urltestGroups = const [];
|
||||||
|
// 轮询计数:每 _urlTestEvery 拍主动触发一次 group/delay(补偿 urltest 惰性探测)。
|
||||||
|
int _pollTick = 0;
|
||||||
|
static const int _urlTestEvery = 12;
|
||||||
|
|
||||||
final _statusCtrl = StreamController<VpnStatus>.broadcast();
|
final _statusCtrl = StreamController<VpnStatus>.broadcast();
|
||||||
final _logCtrl = StreamController<String>.broadcast();
|
final _logCtrl = StreamController<String>.broadcast();
|
||||||
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
|
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
|
||||||
@@ -525,6 +547,7 @@ class DesktopKernelProcess implements KernelProcess {
|
|||||||
final connFuture = _clashApi!.getConnections();
|
final connFuture = _clashApi!.getConnections();
|
||||||
final proxiesFuture = _clashApi!.getProxies().then((p) {
|
final proxiesFuture = _clashApi!.getProxies().then((p) {
|
||||||
_lastUrltestResults = ClashApiClient.extractUrltestResults(p);
|
_lastUrltestResults = ClashApiClient.extractUrltestResults(p);
|
||||||
|
_urltestGroups = ClashApiClient.extractUrltestGroups(p);
|
||||||
}).catchError((_) {
|
}).catchError((_) {
|
||||||
// 读取失败静默跳过,保留上次缓存值
|
// 读取失败静默跳过,保留上次缓存值
|
||||||
});
|
});
|
||||||
@@ -532,6 +555,16 @@ class DesktopKernelProcess implements KernelProcess {
|
|||||||
final data = await connFuture;
|
final data = await connFuture;
|
||||||
await proxiesFuture; // 等 URLTest 解析完再组帧
|
await proxiesFuture; // 等 URLTest 解析完再组帧
|
||||||
|
|
||||||
|
// 周期主动触发 group/delay 探测:sing-box urltest 默认惰性探测,
|
||||||
|
// 仅读 /proxies 缓存会长时间拿不到 history → 延迟空白。每 _urlTestEvery
|
||||||
|
// 拍主动打一次,fire-and-forget(不阻塞主统计帧),下一拍即读到新 history。
|
||||||
|
if ((_pollTick++ % _urlTestEvery) == 0) {
|
||||||
|
for (final g in _urltestGroups) {
|
||||||
|
unawaited(_clashApi!.getGroupDelay(g).catchError(
|
||||||
|
(_) => <String, int>{}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final downTotal =
|
final downTotal =
|
||||||
(data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal;
|
(data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal;
|
||||||
final upTotal =
|
final upTotal =
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// 覆盖:/v1/me、/v1/plans、/v1/me/devices(列表+删除)、/v1/usage、
|
// 覆盖:/v1/me、/v1/plans、/v1/me/devices(列表+删除)、/v1/usage、
|
||||||
// /v1/redeem、/v1/ads/unlock。错误统一 AuthApiException(由 ApiClient 抛)。
|
// /v1/redeem、/v1/ads/unlock。错误统一 AuthApiException(由 ApiClient 抛)。
|
||||||
import '../models/device.dart';
|
import '../models/device.dart';
|
||||||
|
import '../models/device_usage.dart';
|
||||||
import '../models/me.dart';
|
import '../models/me.dart';
|
||||||
import '../models/plan.dart';
|
import '../models/plan.dart';
|
||||||
import '../models/usage_point.dart';
|
import '../models/usage_point.dart';
|
||||||
@@ -65,6 +66,13 @@ class AccountApi {
|
|||||||
return raw.map((e) => UsagePoint.fromJson(e as Map<String, dynamic>)).toList();
|
return raw.map((e) => UsagePoint.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /v1/usage/devices?days=N — 最近 N 天按设备用量(默认 7,范围 [1,90])。
|
||||||
|
Future<List<DeviceUsage>> deviceUsage({int days = 7}) async {
|
||||||
|
final body = await _c.getJson('/v1/usage/devices?days=$days');
|
||||||
|
final raw = (body['devices'] as List<dynamic>?) ?? const [];
|
||||||
|
return raw.map((e) => DeviceUsage.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /v1/redeem — 兑换码。
|
/// POST /v1/redeem — 兑换码。
|
||||||
Future<RedeemResult> redeem(String code) async =>
|
Future<RedeemResult> redeem(String code) async =>
|
||||||
RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code}));
|
RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code}));
|
||||||
|
|||||||
@@ -240,6 +240,53 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── URLTest 延迟/组名提取 ─────────────────────────────────────
|
||||||
|
group('URLTest extraction', () {
|
||||||
|
final proxies = <String, dynamic>{
|
||||||
|
'proxies': {
|
||||||
|
'auto': {
|
||||||
|
'type': 'URLTest',
|
||||||
|
'all': ['reality-out', 'hy2-out'],
|
||||||
|
},
|
||||||
|
'reality-out': {
|
||||||
|
'type': 'Vless',
|
||||||
|
'history': [
|
||||||
|
{'time': 't1', 'delay': 30},
|
||||||
|
{'time': 't2', 'delay': 42},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'hy2-out': {
|
||||||
|
'type': 'Hysteria2',
|
||||||
|
'history': [
|
||||||
|
{'time': 't1', 'delay': 88},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'direct': {'type': 'Direct'},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('extractUrltestGroups 返回 URLTest/Fallback 组名', () {
|
||||||
|
expect(ClashApiClient.extractUrltestGroups(proxies), ['auto']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractUrltestResults 取成员最新 history delay', () {
|
||||||
|
final r = ClashApiClient.extractUrltestResults(proxies);
|
||||||
|
final byTag = {for (final e in r) e.tag: e.delayMs};
|
||||||
|
expect(byTag['reality-out'], 42); // history.last
|
||||||
|
expect(byTag['hy2-out'], 88);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无 URLTest 组时返回空', () {
|
||||||
|
final empty = {
|
||||||
|
'proxies': {
|
||||||
|
'direct': {'type': 'Direct'},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(ClashApiClient.extractUrltestGroups(empty), isEmpty);
|
||||||
|
expect(ClashApiClient.extractUrltestResults(empty), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── 2. 随机生成工具函数 ───────────────────────────────────────
|
// ── 2. 随机生成工具函数 ───────────────────────────────────────
|
||||||
group('Port and secret generators', () {
|
group('Port and secret generators', () {
|
||||||
test('generateClashApiPort is in range 49152–65535', () {
|
test('generateClashApiPort is in range 49152–65535', () {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||||
import 'package:pangolin_vpn/l10n/app_text.dart';
|
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||||
|
import 'package:pangolin_vpn/models/device_usage.dart';
|
||||||
import 'package:pangolin_vpn/models/me.dart';
|
import 'package:pangolin_vpn/models/me.dart';
|
||||||
import 'package:pangolin_vpn/models/node.dart';
|
import 'package:pangolin_vpn/models/node.dart';
|
||||||
import 'package:pangolin_vpn/models/usage_point.dart';
|
import 'package:pangolin_vpn/models/usage_point.dart';
|
||||||
@@ -73,6 +74,13 @@ final List<UsagePoint> _demoUsage = List.generate(30, (i) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分设备用量演示数据(统计页「设备明细」section):三台设备,流量递减以验证占比条。
|
||||||
|
const List<DeviceUsage> _demoDeviceUsage = [
|
||||||
|
DeviceUsage(uuid: 'd1', name: 'iPhone 15', platform: 'ios', bytesUp: 1500 * 1024 * 1024, bytesDown: 9800 * 1024 * 1024, minutesUsed: 312),
|
||||||
|
DeviceUsage(uuid: 'd2', name: 'MacBook Pro', platform: 'macos', bytesUp: 800 * 1024 * 1024, bytesDown: 5200 * 1024 * 1024, minutesUsed: 188),
|
||||||
|
DeviceUsage(uuid: 'd3', name: 'Pixel 8', platform: 'android', bytesUp: 200 * 1024 * 1024, bytesDown: 1400 * 1024 * 1024, minutesUsed: 64),
|
||||||
|
];
|
||||||
|
|
||||||
class _FakeNodesNotifier extends NodesNotifier {
|
class _FakeNodesNotifier extends NodesNotifier {
|
||||||
@override
|
@override
|
||||||
Future<List<Node>> build() async => _demoNodes;
|
Future<List<Node>> build() async => _demoNodes;
|
||||||
@@ -108,6 +116,7 @@ Future<void> _shoot(
|
|||||||
nodesProvider.overrideWith(_FakeNodesNotifier.new),
|
nodesProvider.overrideWith(_FakeNodesNotifier.new),
|
||||||
meProvider.overrideWith(_FakeMeNotifier.new),
|
meProvider.overrideWith(_FakeMeNotifier.new),
|
||||||
usageProvider(30).overrideWith((ref) async => _demoUsage),
|
usageProvider(30).overrideWith((ref) async => _demoUsage),
|
||||||
|
deviceUsageProvider(30).overrideWith((ref) async => _demoDeviceUsage),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user