From 4f9d2d2cf315ad5688cfb35bf2a8e252960bab11 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 28 Jun 2026 17:53:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E8=A1=A5=E6=8F=90=E4=BA=A4=20st?= =?UTF-8?q?ats-overhaul=20=E6=BC=8F=E6=8E=89=E7=9A=84=20account=5Fapi/kern?= =?UTF-8?q?el=5Fprocess=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同 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 --- client/lib/bridge/kernel_process.dart | 33 +++++++++++++ client/lib/services/account_api.dart | 8 ++++ client/test/bridge/kernel_process_test.dart | 47 +++++++++++++++++++ .../test/golden/tablet_pages_golden_test.dart | 9 ++++ 4 files changed, 97 insertions(+) diff --git a/client/lib/bridge/kernel_process.dart b/client/lib/bridge/kernel_process.dart index b341214..1c3c81c 100644 --- a/client/lib/bridge/kernel_process.dart +++ b/client/lib/bridge/kernel_process.dart @@ -195,6 +195,22 @@ class ClashApiClient { return data.cast(); } + // ── 工具:从 /proxies 响应提取 URLTest/Fallback 组名 ────────── + static List extractUrltestGroups(Map proxiesResponse) { + final proxies = proxiesResponse['proxies']; + if (proxies is! Map) return const []; + final groups = []; + 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 延迟列表 ────────────── // 扫描所有类型为 URLTest 的出口组,提取成员节点的最新 history delay。 static List extractUrltestResults( @@ -312,6 +328,12 @@ class DesktopKernelProcess implements KernelProcess { // 最新 URLTest 延迟列表(每次 stats 轮询时更新) List _lastUrltestResults = const []; + // URLTest/Fallback 组名(从 /proxies 提取),供主动触发延迟探测。 + List _urltestGroups = const []; + // 轮询计数:每 _urlTestEvery 拍主动触发一次 group/delay(补偿 urltest 惰性探测)。 + int _pollTick = 0; + static const int _urlTestEvery = 12; + final _statusCtrl = StreamController.broadcast(); final _logCtrl = StreamController.broadcast(); final _statsCtrl = StreamController.broadcast(); @@ -525,6 +547,7 @@ class DesktopKernelProcess implements KernelProcess { final connFuture = _clashApi!.getConnections(); final proxiesFuture = _clashApi!.getProxies().then((p) { _lastUrltestResults = ClashApiClient.extractUrltestResults(p); + _urltestGroups = ClashApiClient.extractUrltestGroups(p); }).catchError((_) { // 读取失败静默跳过,保留上次缓存值 }); @@ -532,6 +555,16 @@ class DesktopKernelProcess implements KernelProcess { final data = await connFuture; 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( + (_) => {})); + } + } + final downTotal = (data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal; final upTotal = diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart index 5175e4e..a064faf 100644 --- a/client/lib/services/account_api.dart +++ b/client/lib/services/account_api.dart @@ -3,6 +3,7 @@ // 覆盖:/v1/me、/v1/plans、/v1/me/devices(列表+删除)、/v1/usage、 // /v1/redeem、/v1/ads/unlock。错误统一 AuthApiException(由 ApiClient 抛)。 import '../models/device.dart'; +import '../models/device_usage.dart'; import '../models/me.dart'; import '../models/plan.dart'; import '../models/usage_point.dart'; @@ -65,6 +66,13 @@ class AccountApi { return raw.map((e) => UsagePoint.fromJson(e as Map)).toList(); } + /// GET /v1/usage/devices?days=N — 最近 N 天按设备用量(默认 7,范围 [1,90])。 + Future> deviceUsage({int days = 7}) async { + final body = await _c.getJson('/v1/usage/devices?days=$days'); + final raw = (body['devices'] as List?) ?? const []; + return raw.map((e) => DeviceUsage.fromJson(e as Map)).toList(); + } + /// POST /v1/redeem — 兑换码。 Future redeem(String code) async => RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code})); diff --git a/client/test/bridge/kernel_process_test.dart b/client/test/bridge/kernel_process_test.dart index 6fd292e..9047d5e 100644 --- a/client/test/bridge/kernel_process_test.dart +++ b/client/test/bridge/kernel_process_test.dart @@ -240,6 +240,53 @@ void main() { }); }); + // ── URLTest 延迟/组名提取 ───────────────────────────────────── + group('URLTest extraction', () { + final proxies = { + '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. 随机生成工具函数 ─────────────────────────────────────── group('Port and secret generators', () { test('generateClashApiPort is in range 49152–65535', () { diff --git a/client/test/golden/tablet_pages_golden_test.dart b/client/test/golden/tablet_pages_golden_test.dart index a0a6399..b41e3d4 100644 --- a/client/test/golden/tablet_pages_golden_test.dart +++ b/client/test/golden/tablet_pages_golden_test.dart @@ -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_provider.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/node.dart'; import 'package:pangolin_vpn/models/usage_point.dart'; @@ -73,6 +74,13 @@ final List _demoUsage = List.generate(30, (i) { ); }); +// 分设备用量演示数据(统计页「设备明细」section):三台设备,流量递减以验证占比条。 +const List _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 { @override Future> build() async => _demoNodes; @@ -108,6 +116,7 @@ Future _shoot( nodesProvider.overrideWith(_FakeNodesNotifier.new), meProvider.overrideWith(_FakeMeNotifier.new), usageProvider(30).overrideWith((ref) async => _demoUsage), + deviceUsageProvider(30).overrideWith((ref) async => _demoDeviceUsage), ], child: MaterialApp( debugShowCheckedModeBanner: false,