Files
pangolin/client/lib/screens/stats_page.dart
T
wangjia f35bbea0a0
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
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>
2026-06-19 00:44:46 +08:00

139 lines
5.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// stats_page.dart — 统计页(指标卡 + 本周柱状图)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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';
class StatsPage extends ConsumerWidget {
const StatsPage({super.key, required this.isWide});
final bool isWide;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
// 真实数据:周柱来自 me.weekly_gb;本月流量/时长来自 usage(30);延迟为生效节点实测。
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);
final denom = maxV <= 0 ? 1.0 : maxV;
final monthGb = usage.fold<double>(0, (s, p) => s + p.gbTotal);
final monthHours = usage.fold<int>(0, (s, p) => s + p.minutesUsed) / 60.0;
// desktop 对照 dapp.jsx DStatspadding 32/8、卡 gap 14、柱 height 120·bar×86·宽 34。
final pad = isWide ? 32.0 : 20.0;
final metrics = [
(t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'),
(t.avgPing, latencyMs > 0 ? '$latencyMs' : '', 'ms'),
(t.durMonth, monthHours.toStringAsFixed(1), 'h'),
];
final body = ListView(
padding: EdgeInsets.fromLTRB(pad, isWide ? 8 : 0, pad, 24),
children: [
Row(children: [
for (var i = 0; i < metrics.length; i++)
Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < metrics.length - 1 ? 14 : 0),
child: _MetricCard(label: metrics[i].$1, value: metrics[i].$2, unit: metrics[i].$3),
),
),
]),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.weekTraffic,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 18),
SizedBox(
// 柱最高 86 + 上下数值/星期标签两行 + 间距 ≈ 134,留余量 140 避免溢出。
height: 140,
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
for (var i = 0; i < weekly.length; i++)
Expanded(
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
Text(weekly[i].toStringAsFixed(1), style: PangolinText.mono.copyWith(fontSize: 10.5, color: c.fg3)),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 34),
child: Container(
height: 86 * (weekly[i] / denom),
decoration: BoxDecoration(
color: c.accent.withOpacity(0.85),
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
),
),
),
const SizedBox(height: 8),
Text(t.days7[i], style: PangolinText.caption.copyWith(color: c.fg3)),
]),
),
]),
),
]),
),
],
);
if (isWide) return body;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AppTopBar(brand: t.brand),
PageTitle(title: t.statsTitle),
Expanded(child: body),
]);
}
}
class _MetricCard extends StatelessWidget {
const _MetricCard({required this.label, required this.value, required this.unit});
final String label, value, unit;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 7),
Text.rich(TextSpan(
text: value,
style: PangolinText.mono.copyWith(fontSize: 24, color: c.fg1, fontWeight: FontWeight.w500),
children: [TextSpan(text: ' $unit', style: PangolinText.caption.copyWith(color: c.fg3))],
)),
]),
);
}
}