fix(client): 连接后延迟用内核 urltest 实测 + 侧栏套餐到期日接真
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

- 延迟: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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 00:44:46 +08:00
parent 93ec421c41
commit f35bbea0a0
3 changed files with 45 additions and 14 deletions
+28 -12
View File
@@ -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 = <Widget>[
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(
+10 -1
View File
@@ -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 <UsagePoint>[];
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<double>.filled(7, 0.0);
final maxV = weekly.fold<double>(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'),
];
+7 -1
View File
@@ -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),
),