feat(client): P4 节点实测延迟/设备/套餐价/兑换/统计接真(#6 6D)
- latency_probe.dart:并行 TCP 握手实测各节点 per-client RTT(服务端无法代知)。 - nodes_provider:解析 /v1/nodes 的 host/port,后台测延迟回填 Node.ping;智能选择 按实测最小;去掉 8 个伪造演示节点(空列表用中性占位,不再伪造服务器)。 - Node:+host/port/copyWith/pingLabel(未测显示 —);各端 ping 显示改 pingLabel。 - account_screens:PlansScreen 接 /v1/plans(价格 priceLabel + me 标当前档); DevicesScreen 接 /v1/me/devices + 移除 DELETE + last_seen;RedeemScreen 接 /v1/redeem(成功刷新 me,失败显示后端文案)。 - stats_page:周柱接 me.weekly_gb,本月流量/时长接 usage(30),延迟用生效节点实测。 - 顺带补登记早前 log_time.dart 的删除(log.dart 重构遗漏 stage)。 flutter analyze 0 error;115 tests passed;受影响 4 golden 重生成。 本机设备高亮 + 真实 device id 随 P6(device_info_plus)落地。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -186,7 +186,7 @@ class _SpeedRow extends StatelessWidget {
|
||||
final metrics = [
|
||||
(PangolinIcons.arrowDown, t.download, '86.4', 'Mb/s'),
|
||||
(PangolinIcons.arrowUp, t.upload, '12.1', 'Mb/s'),
|
||||
(PangolinIcons.zap, t.latency, '${node.ping}', 'ms'),
|
||||
(PangolinIcons.zap, t.latency, node.ping > 0 ? '${node.ping}' : '—', 'ms'),
|
||||
];
|
||||
return Row(children: [
|
||||
for (var i = 0; i < metrics.length; i++)
|
||||
@@ -245,8 +245,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.ping}ms'
|
||||
: '${node.localizedSub(t.lang)} · ${node.ping}ms';
|
||||
? '${node.localizedName(t.lang)} · ${node.pingLabel}'
|
||||
: '${node.localizedSub(t.lang)} · ${node.pingLabel}';
|
||||
return Material(
|
||||
color: c.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -296,8 +296,8 @@ class _NodePill extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final label = smart
|
||||
? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.ping}ms'
|
||||
: '${node.localizedName(t.lang)} · ${node.ping}ms';
|
||||
? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.pingLabel}'
|
||||
: '${node.localizedName(t.lang)} · ${node.pingLabel}';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final selected = ref.watch(selectedNodeCodeProvider);
|
||||
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? kDemoNodes);
|
||||
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? const <Node>[]);
|
||||
final smart = selected == kSmartNodeCode;
|
||||
|
||||
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode));
|
||||
@@ -184,7 +184,7 @@ class _NodeGridTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('${node.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
Text(node.pingLabel, style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
const SizedBox(height: 5),
|
||||
SignalBars(ping: node.ping),
|
||||
]),
|
||||
|
||||
@@ -2,28 +2,39 @@
|
||||
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/nodes_provider.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
|
||||
class StatsPage extends ConsumerWidget {
|
||||
const StatsPage({super.key, required this.isWide});
|
||||
final bool isWide;
|
||||
|
||||
static const _vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final maxV = _vals.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
// 真实数据:周柱来自 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);
|
||||
|
||||
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 DStats:padding 32/8、卡 gap 14、柱 height 120·bar×86·宽 34。
|
||||
final pad = isWide ? 32.0 : 20.0;
|
||||
|
||||
final metrics = [
|
||||
(t.trafficMonth, '42.6', 'GB'),
|
||||
(t.avgPing, '29', 'ms'),
|
||||
(t.durMonth, '86.4', 'h'),
|
||||
(t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'),
|
||||
(t.avgPing, node.ping > 0 ? '${node.ping}' : '—', 'ms'),
|
||||
(t.durMonth, monthHours.toStringAsFixed(1), 'h'),
|
||||
];
|
||||
|
||||
final body = ListView(
|
||||
@@ -55,15 +66,15 @@ class StatsPage extends ConsumerWidget {
|
||||
// 柱最高 86 + 上下数值/星期标签两行 + 间距 ≈ 134,留余量 140 避免溢出。
|
||||
height: 140,
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
for (var i = 0; i < _vals.length; i++)
|
||||
for (var i = 0; i < weekly.length; i++)
|
||||
Expanded(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Text('${_vals[i]}', style: PangolinText.mono.copyWith(fontSize: 10.5, color: c.fg3)),
|
||||
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 * (_vals[i] / maxV),
|
||||
height: 86 * (weekly[i] / denom),
|
||||
decoration: BoxDecoration(
|
||||
color: c.accent.withOpacity(0.85),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
|
||||
Reference in New Issue
Block a user