From f35bbea0a058fde683294e92a4bbbf7097a7e940 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 00:44:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E8=BF=9E=E6=8E=A5=E5=90=8E?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F=E7=94=A8=E5=86=85=E6=A0=B8=20urltest=20?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=20+=20=E4=BE=A7=E6=A0=8F=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E6=97=A5=E6=8E=A5=E7=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 延迟:TCP 探针在隧道开启时会被路由进隧道而测不到(显示 —)。改为连接后 (phase==on)取内核 statsStream 的 urltest delayMs 最小正值(代理真实 RTT), 未连接时仍用节点 TCP 探针。连接页 _SpeedRow/_NodePill/_CurrentNodeCard 与统计页平均延迟统一此口径。 - plan_badge_card(侧栏)接 me.expiresAt 显示真实到期日(原只显示「有效期至」 无日期);account_page 之外漏改的最后一处。 flutter analyze 0 error;114 tests passed。 Co-Authored-By: Claude Opus 4.8 --- client/lib/screens/connect_page.dart | 40 +++++++++++++++++-------- client/lib/screens/stats_page.dart | 11 ++++++- client/lib/widgets/plan_badge_card.dart | 8 ++++- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 2cc1dfe..de7c6d8 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../bridge/vpn_bridge.dart'; import '../core/responsive/form_factor.dart'; import '../l10n/app_text.dart'; import '../models/node.dart'; @@ -34,6 +35,11 @@ class ConnectPage extends ConsumerWidget { final stats = ref.watch(vpnStatsProvider).valueOrNull; final down = _mbps(stats?.downloadSpeed); final up = _mbps(stats?.uploadSpeed); + // 延迟来源:连接后用内核 urltest 实测(代理真实 RTT,TCP 探针被隧道吞掉时仍准); + // 未连接时用节点 TCP 探针 ping。0/无 → 显示 —。 + final livePing = conn.phase == VpnPhase.on ? _bestUrltest(stats) : node.ping; + final pingLabel = livePing > 0 ? '${livePing}ms' : '—'; + final latencyValue = livePing > 0 ? '$livePing' : '—'; final caption = switch (conn.phase) { VpnPhase.off => t.capOff, @@ -65,8 +71,8 @@ class ConnectPage extends ConsumerWidget { final infoChildren = [ if (isFree) QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()), - if (conn.phase == VpnPhase.on) _SpeedRow(t: t, node: node, down: down, up: up), - _CurrentNodeCard(t: t, node: node, smart: smart, showLabel: isWide, onTap: onOpenNodes), + if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue), + _CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes), ]; final statusTrailing = Text( @@ -104,7 +110,7 @@ class ConnectPage extends ConsumerWidget { style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)), ), ], - _NodePill(t: t, node: node, smart: smart), + _NodePill(t: t, node: node, smart: smart, pingLabel: pingLabel), if (isFree) ...[ const SizedBox(height: 24), SizedBox( @@ -114,7 +120,7 @@ class ConnectPage extends ConsumerWidget { ], if (conn.phase == VpnPhase.on) ...[ const SizedBox(height: 16), - _SpeedRow(t: t, node: node, down: down, up: up), + _SpeedRow(t: t, down: down, up: up, latency: latencyValue), ], ]), ), @@ -199,13 +205,20 @@ class ConnectPage extends ConsumerWidget { String _mbps(double? bytesPerSec) => bytesPerSec == null ? '—' : (bytesPerSec * 8 / 1000000).toStringAsFixed(1); +/// 内核 urltest 各出站中的最小正延迟(ms);无则 0。 +int _bestUrltest(VpnStatsEvent? s) { + if (s == null) return 0; + final ds = s.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs); + return ds.isEmpty ? 0 : ds.reduce((a, b) => a < b ? a : b); +} + /// 实时速率行(连接成功时显示;来自内核 statsStream 实时数据)。 class _SpeedRow extends StatelessWidget { - const _SpeedRow({required this.t, required this.node, required this.down, required this.up}); + const _SpeedRow({required this.t, required this.down, required this.up, required this.latency}); final AppText t; - final Node node; final String down; final String up; + final String latency; @override Widget build(BuildContext context) { @@ -213,7 +226,7 @@ class _SpeedRow extends StatelessWidget { final metrics = [ (PangolinIcons.arrowDown, t.download, down, 'Mb/s'), (PangolinIcons.arrowUp, t.upload, up, 'Mb/s'), - (PangolinIcons.zap, t.latency, node.ping > 0 ? '${node.ping}' : '—', 'ms'), + (PangolinIcons.zap, t.latency, latency, 'ms'), ]; return Row(children: [ for (var i = 0; i < metrics.length; i++) @@ -258,12 +271,14 @@ class _CurrentNodeCard extends StatelessWidget { required this.t, required this.node, required this.smart, + required this.pingLabel, required this.showLabel, required this.onTap, }); final AppText t; final Node node; final bool smart; + final String pingLabel; final bool showLabel; final VoidCallback onTap; @@ -272,8 +287,8 @@ class _CurrentNodeCard extends StatelessWidget { final c = context.pangolin; final title = smart ? t.smartSelect : node.localizedName(t.lang); final sub = smart - ? '${node.localizedName(t.lang)} · ${node.pingLabel}' - : '${node.localizedSub(t.lang)} · ${node.pingLabel}'; + ? '${node.localizedName(t.lang)} · $pingLabel' + : '${node.localizedSub(t.lang)} · $pingLabel'; return Material( color: c.surface, shape: RoundedRectangleBorder( @@ -314,17 +329,18 @@ class _CurrentNodeCard extends StatelessWidget { /// 桌面连接页节点胶囊([zap] 智能选择 · 节点名 · 延迟ms)。 class _NodePill extends StatelessWidget { - const _NodePill({required this.t, required this.node, required this.smart}); + const _NodePill({required this.t, required this.node, required this.smart, required this.pingLabel}); final AppText t; final Node node; final bool smart; + final String pingLabel; @override Widget build(BuildContext context) { final c = context.pangolin; final label = smart - ? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.pingLabel}' - : '${node.localizedName(t.lang)} · ${node.pingLabel}'; + ? '${t.smartSelect} · ${node.localizedName(t.lang)} · $pingLabel' + : '${node.localizedName(t.lang)} · $pingLabel'; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index 47fd7d1..3b78873 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -6,6 +6,7 @@ import '../models/usage_point.dart'; import '../pangolin_theme.dart'; import '../state/account_providers.dart'; import '../state/app_providers.dart'; +import '../state/connection_provider.dart'; import '../state/nodes_provider.dart'; import '../widgets/app_top_bar.dart'; @@ -22,6 +23,14 @@ class StatsPage extends ConsumerWidget { final me = ref.watch(meProvider).valueOrNull; final usage = ref.watch(usageProvider(30)).valueOrNull ?? const []; final node = ref.watch(effectiveNodeProvider); + // 延迟:连接后取内核 urltest 实测最小值,否则取节点 TCP 探针 ping。 + final stats = ref.watch(vpnStatsProvider).valueOrNull; + final connected = ref.watch(connectionProvider).phase == VpnPhase.on; + var latencyMs = node.ping; + if (connected && stats != null) { + final ds = stats.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs); + if (ds.isNotEmpty) latencyMs = ds.reduce((a, b) => a < b ? a : b); + } final weekly = (me?.weeklyGb.length == 7) ? me!.weeklyGb : List.filled(7, 0.0); final maxV = weekly.fold(0, (a, b) => a > b ? a : b); @@ -33,7 +42,7 @@ class StatsPage extends ConsumerWidget { final metrics = [ (t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'), - (t.avgPing, node.ping > 0 ? '${node.ping}' : '—', 'ms'), + (t.avgPing, latencyMs > 0 ? '$latencyMs' : '—', 'ms'), (t.durMonth, monthHours.toStringAsFixed(1), 'h'), ]; diff --git a/client/lib/widgets/plan_badge_card.dart b/client/lib/widgets/plan_badge_card.dart index 383ff59..c0501ac 100644 --- a/client/lib/widgets/plan_badge_card.dart +++ b/client/lib/widgets/plan_badge_card.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../pangolin_theme.dart'; +import '../state/account_providers.dart'; import '../state/app_providers.dart'; import 'pangolin_icons.dart'; @@ -17,6 +18,11 @@ class PlanBadgeCard extends ConsumerWidget { final c = context.pangolin; final t = ref.watch(appTextProvider); final isFree = ref.watch(isFreePlanProvider); + final exp = ref.watch(meProvider).valueOrNull?.expiresAt; + String two(int v) => v.toString().padLeft(2, '0'); + final expLine = exp == null + ? t.expires + : '${t.expires} ${exp.toLocal().year}-${two(exp.toLocal().month)}-${two(exp.toLocal().day)}'; return Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), @@ -51,7 +57,7 @@ class PlanBadgeCard extends ConsumerWidget { style: PangolinText.caption.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 12), ), Text( - isFree ? t.quotaFree : t.expires, + isFree ? t.quotaFree : expLine, overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 10.5), ),