feat(client/stats): 统计页重设计 — 月/周/日周期卡 + 设备下拉 + 两周折线 + 修刷新
布局重做(对照确认稿): - 3 张周期卡(本月/本周/今日),每张 = ↓下行 · ↑上行 · 使用时长(PeriodCard) - 右上设备下拉(_DeviceDropdown,先只挂「全部」;每设备归因打通后填设备名,见 TODO #9/#10) - 柱状图 → 折线图:最近两周(14 天)按天流量(UsageLineChart,CustomPainter 零依赖) 数据源统一 + 修刷新(根治「本月流量冻住」): - 月/周/日 + 折线全从单一源 usageProvider(30) 聚合(取最后 N 天点求和,末点即今日, 避开 UTC/本地日期坑);不再 me.weekly + usage 两条管线 - usageProvider/deviceUsageProvider 改 autoDispose(进页重取)+ 下拉刷新(invalidate) + 刷新连带 meProvider.refresh。后端每 60s 更新,无需更勤 UI 全走 PangolinColors/Text/Spacing token 单源、零硬编码;中英 l10n key 补齐。 widget 数值断言测试(刀1)按新结构重写并通过;统计/连接页 golden 重生成 (非 CI 闸;components/auth 基线未动)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// stats_page.dart — 统计页(指标卡 + 本周柱状图)
|
||||
// stats_page.dart — 统计页(月/周/日 周期卡 + 设备下拉 + 最近两周流量折线)。
|
||||
// 单一数据源:近30天每日用量(usageProvider,autoDispose 进页重取),月/周/日 + 折线全从此聚合。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -6,9 +7,10 @@ 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';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/period_card.dart';
|
||||
import '../widgets/usage_line_chart.dart';
|
||||
|
||||
class StatsPage extends ConsumerWidget {
|
||||
const StatsPage({super.key, required this.isWide});
|
||||
@@ -18,87 +20,83 @@ class StatsPage extends ConsumerWidget {
|
||||
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);
|
||||
|
||||
// 周期聚合:取最后 N 天的点求和。最后一点即今日(避开 UTC/本地日期换算)。
|
||||
(int down, int up, int min) sumLast(int days) {
|
||||
var d = 0, u = 0, m = 0;
|
||||
final start = usage.length > days ? usage.length - days : 0;
|
||||
for (var i = start; i < usage.length; i++) {
|
||||
d += usage[i].bytesDown;
|
||||
u += usage[i].bytesUp;
|
||||
m += usage[i].minutesUsed;
|
||||
}
|
||||
return (d, u, m);
|
||||
}
|
||||
|
||||
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;
|
||||
List<StatMetric> metricsFor(int days) {
|
||||
final (d, u, m) = sumLast(days);
|
||||
final dn = _fmtBytes(d), up = _fmtBytes(u);
|
||||
return [
|
||||
StatMetric(dn.$1, dn.$2, '↓ ${t.statDown}'),
|
||||
StatMetric(up.$1, up.$2, '↑ ${t.statUp}'),
|
||||
StatMetric((m / 60.0).toStringAsFixed(1), 'h', t.statDuration),
|
||||
];
|
||||
}
|
||||
|
||||
final metrics = [
|
||||
(t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'),
|
||||
(t.avgPing, latencyMs > 0 ? '$latencyMs' : '—', 'ms'),
|
||||
(t.durMonth, monthHours.toStringAsFixed(1), 'h'),
|
||||
// 折线:最后 14 天每日总流量(GB)。
|
||||
final chart = <double>[
|
||||
for (var i = usage.length > 14 ? usage.length - 14 : 0; i < usage.length; i++) usage[i].gbTotal,
|
||||
];
|
||||
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
final body = RefreshIndicator(
|
||||
color: c.accent,
|
||||
backgroundColor: c.surface,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(usageProvider(30));
|
||||
ref.invalidate(deviceUsageProvider(30));
|
||||
await ref.read(meProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(isWide ? 32 : 20, isWide ? 8 : 4, isWide ? 32 : 20, 24),
|
||||
children: [
|
||||
// 顶行:标题(宽屏) + 设备下拉(右上角)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
if (isWide)
|
||||
Text(t.statsTitle,
|
||||
style: PangolinText.display.copyWith(color: c.fg1, fontSize: 20, fontWeight: FontWeight.w700))
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
_DeviceDropdown(label: t.allDevices),
|
||||
]),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
PeriodCard(period: t.periodMonth, metrics: metricsFor(30)),
|
||||
const SizedBox(height: 12),
|
||||
PeriodCard(period: t.periodWeek, metrics: metricsFor(7)),
|
||||
const SizedBox(height: 12),
|
||||
PeriodCard(period: t.periodToday, metrics: metricsFor(1)),
|
||||
const SizedBox(height: 20),
|
||||
// 折线卡
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
|
||||
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: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(t.chartTwoWeeks,
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
Text('GB', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 11)),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
UsageLineChart(values: chart, accent: c.accent, grid: c.border),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (isWide) return body;
|
||||
@@ -110,29 +108,49 @@ class StatsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCard extends StatelessWidget {
|
||||
const _MetricCard({required this.label, required this.value, required this.unit});
|
||||
final String label, value, unit;
|
||||
/// 字节 → (数值, 单位):<1GB 显示 MB,否则 GB(各 1 位小数)。
|
||||
(String, String) _fmtBytes(int bytes) {
|
||||
const gb = 1024 * 1024 * 1024, mb = 1024 * 1024;
|
||||
if (bytes >= gb) return ((bytes / gb).toStringAsFixed(1), 'GB');
|
||||
return ((bytes / mb).toStringAsFixed(1), 'MB');
|
||||
}
|
||||
|
||||
/// 设备下拉(右上角):全部 + 近30天用过的设备名。
|
||||
/// 当前每设备归因未打通(usage_device_daily 常空),先只挂「全部」;
|
||||
/// 设备注册打通后(TODO #9/#10)填充设备名 + 按设备过滤。
|
||||
class _DeviceDropdown extends StatelessWidget {
|
||||
const _DeviceDropdown({required this.label});
|
||||
final String label;
|
||||
|
||||
@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,
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: '',
|
||||
offset: const Offset(0, 38),
|
||||
color: c.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
side: BorderSide(color: c.border),
|
||||
),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(value: '', child: Text(label, style: PangolinText.sm.copyWith(color: c.fg1))),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 10, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
border: Border.all(color: c.borderStrong),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(PangolinIcons.smartphone, size: 14, color: c.accent),
|
||||
const SizedBox(width: 7),
|
||||
Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12.5)),
|
||||
const SizedBox(width: 5),
|
||||
Icon(PangolinIcons.chevronDown, size: 13, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
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))],
|
||||
)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user