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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../bridge/vpn_bridge.dart';
|
||||||
import '../core/responsive/form_factor.dart';
|
import '../core/responsive/form_factor.dart';
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
import '../models/node.dart';
|
import '../models/node.dart';
|
||||||
@@ -34,6 +35,11 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
final stats = ref.watch(vpnStatsProvider).valueOrNull;
|
final stats = ref.watch(vpnStatsProvider).valueOrNull;
|
||||||
final down = _mbps(stats?.downloadSpeed);
|
final down = _mbps(stats?.downloadSpeed);
|
||||||
final up = _mbps(stats?.uploadSpeed);
|
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) {
|
final caption = switch (conn.phase) {
|
||||||
VpnPhase.off => t.capOff,
|
VpnPhase.off => t.capOff,
|
||||||
@@ -65,8 +71,8 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
final infoChildren = <Widget>[
|
final infoChildren = <Widget>[
|
||||||
if (isFree)
|
if (isFree)
|
||||||
QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
|
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),
|
if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue),
|
||||||
_CurrentNodeCard(t: t, node: node, smart: smart, showLabel: isWide, onTap: onOpenNodes),
|
_CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes),
|
||||||
];
|
];
|
||||||
|
|
||||||
final statusTrailing = Text(
|
final statusTrailing = Text(
|
||||||
@@ -104,7 +110,7 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)),
|
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) ...[
|
if (isFree) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -114,7 +120,7 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
if (conn.phase == VpnPhase.on) ...[
|
if (conn.phase == VpnPhase.on) ...[
|
||||||
const SizedBox(height: 16),
|
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) =>
|
String _mbps(double? bytesPerSec) =>
|
||||||
bytesPerSec == null ? '—' : (bytesPerSec * 8 / 1000000).toStringAsFixed(1);
|
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 实时数据)。
|
/// 实时速率行(连接成功时显示;来自内核 statsStream 实时数据)。
|
||||||
class _SpeedRow extends StatelessWidget {
|
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 AppText t;
|
||||||
final Node node;
|
|
||||||
final String down;
|
final String down;
|
||||||
final String up;
|
final String up;
|
||||||
|
final String latency;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -213,7 +226,7 @@ class _SpeedRow extends StatelessWidget {
|
|||||||
final metrics = [
|
final metrics = [
|
||||||
(PangolinIcons.arrowDown, t.download, down, 'Mb/s'),
|
(PangolinIcons.arrowDown, t.download, down, 'Mb/s'),
|
||||||
(PangolinIcons.arrowUp, t.upload, up, '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: [
|
return Row(children: [
|
||||||
for (var i = 0; i < metrics.length; i++)
|
for (var i = 0; i < metrics.length; i++)
|
||||||
@@ -258,12 +271,14 @@ class _CurrentNodeCard extends StatelessWidget {
|
|||||||
required this.t,
|
required this.t,
|
||||||
required this.node,
|
required this.node,
|
||||||
required this.smart,
|
required this.smart,
|
||||||
|
required this.pingLabel,
|
||||||
required this.showLabel,
|
required this.showLabel,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
final AppText t;
|
final AppText t;
|
||||||
final Node node;
|
final Node node;
|
||||||
final bool smart;
|
final bool smart;
|
||||||
|
final String pingLabel;
|
||||||
final bool showLabel;
|
final bool showLabel;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@@ -272,8 +287,8 @@ class _CurrentNodeCard extends StatelessWidget {
|
|||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
final title = smart ? t.smartSelect : node.localizedName(t.lang);
|
final title = smart ? t.smartSelect : node.localizedName(t.lang);
|
||||||
final sub = smart
|
final sub = smart
|
||||||
? '${node.localizedName(t.lang)} · ${node.pingLabel}'
|
? '${node.localizedName(t.lang)} · $pingLabel'
|
||||||
: '${node.localizedSub(t.lang)} · ${node.pingLabel}';
|
: '${node.localizedSub(t.lang)} · $pingLabel';
|
||||||
return Material(
|
return Material(
|
||||||
color: c.surface,
|
color: c.surface,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -314,17 +329,18 @@ class _CurrentNodeCard extends StatelessWidget {
|
|||||||
|
|
||||||
/// 桌面连接页节点胶囊([zap] 智能选择 · 节点名 · 延迟ms)。
|
/// 桌面连接页节点胶囊([zap] 智能选择 · 节点名 · 延迟ms)。
|
||||||
class _NodePill extends StatelessWidget {
|
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 AppText t;
|
||||||
final Node node;
|
final Node node;
|
||||||
final bool smart;
|
final bool smart;
|
||||||
|
final String pingLabel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
final label = smart
|
final label = smart
|
||||||
? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.pingLabel}'
|
? '${t.smartSelect} · ${node.localizedName(t.lang)} · $pingLabel'
|
||||||
: '${node.localizedName(t.lang)} · ${node.pingLabel}';
|
: '${node.localizedName(t.lang)} · $pingLabel';
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/usage_point.dart';
|
|||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
import '../state/account_providers.dart';
|
import '../state/account_providers.dart';
|
||||||
import '../state/app_providers.dart';
|
import '../state/app_providers.dart';
|
||||||
|
import '../state/connection_provider.dart';
|
||||||
import '../state/nodes_provider.dart';
|
import '../state/nodes_provider.dart';
|
||||||
import '../widgets/app_top_bar.dart';
|
import '../widgets/app_top_bar.dart';
|
||||||
|
|
||||||
@@ -22,6 +23,14 @@ class StatsPage extends ConsumerWidget {
|
|||||||
final me = ref.watch(meProvider).valueOrNull;
|
final me = ref.watch(meProvider).valueOrNull;
|
||||||
final usage = ref.watch(usageProvider(30)).valueOrNull ?? const <UsagePoint>[];
|
final usage = ref.watch(usageProvider(30)).valueOrNull ?? const <UsagePoint>[];
|
||||||
final node = ref.watch(effectiveNodeProvider);
|
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 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);
|
final maxV = weekly.fold<double>(0, (a, b) => a > b ? a : b);
|
||||||
@@ -33,7 +42,7 @@ class StatsPage extends ConsumerWidget {
|
|||||||
|
|
||||||
final metrics = [
|
final metrics = [
|
||||||
(t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'),
|
(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'),
|
(t.durMonth, monthHours.toStringAsFixed(1), 'h'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
|
import '../state/account_providers.dart';
|
||||||
import '../state/app_providers.dart';
|
import '../state/app_providers.dart';
|
||||||
import 'pangolin_icons.dart';
|
import 'pangolin_icons.dart';
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ class PlanBadgeCard extends ConsumerWidget {
|
|||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
final t = ref.watch(appTextProvider);
|
final t = ref.watch(appTextProvider);
|
||||||
final isFree = ref.watch(isFreePlanProvider);
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
|
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),
|
style: PangolinText.caption.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 12),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
isFree ? t.quotaFree : t.expires,
|
isFree ? t.quotaFree : expLine,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 10.5),
|
style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 10.5),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user