fix(client): 连接后延迟用内核 urltest 实测 + 侧栏套餐到期日接真
- 延迟: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:
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user