8a1db07d19
ci-pangolin / Lint — shellcheck (push) Successful in 9s
ci-pangolin / OpenAPI Sync Check (push) Successful in 19s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Failing after 15s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m2s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
① 「我的设备打不开」根因:devices handler 读 devices.CtxKeyUserID,而鉴权中间件 把 user id 写在 auth 的 key 下(类型不同→取不到)→ /v1/me/devices 一直 401。 改 handler 用 auth.UserIDFromContext(与 accountAPI 等一致)。 ② 统计页设备下拉改读 devicesProvider(已登录设备),不再只列有用量的设备 → windows 设备现在会出现。 ③ pubspec version 1.0.2+3 → 1.0.11+12,client_version 不再显示陈旧的 v1.0.2。 测试:devices_screen + stats_device_filter widget(下拉读 /v1/me/devices)+全量绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
196 lines
7.9 KiB
Dart
196 lines
7.9 KiB
Dart
// stats_page.dart — 统计页(月/周/日 周期卡 + 设备下拉 + 最近两周流量折线)。
|
|
// 单一数据源:近30天每日用量(usageProvider,autoDispose 进页重取),月/周/日 + 折线全从此聚合。
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../l10n/app_text.dart';
|
|
import '../models/device.dart';
|
|
import '../models/usage_point.dart';
|
|
import '../pangolin_theme.dart';
|
|
import '../state/account_providers.dart';
|
|
import '../state/app_providers.dart';
|
|
import '../state/auth_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});
|
|
final bool isWide;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final c = context.pangolin;
|
|
final t = ref.watch(appTextProvider);
|
|
// 选中设备(#10②):null=全部=账户级;否则只算该设备的本地日曲线。
|
|
final selDevice = ref.watch(statsDeviceProvider);
|
|
final usageKey = (days: 30, device: selDevice);
|
|
final usageAsync = ref.watch(usageProvider(usageKey));
|
|
final usage = usageAsync.valueOrNull ?? const <UsagePoint>[];
|
|
// 先拉数据再渲染(不闪默认 0):auth 还在读 token,或已登录但 usage 还在拉 → 整页转圈。
|
|
// (auth 加载期 isLoggedIn=false → usage 会同步返回空,故必须并上 auth.isLoading。)
|
|
final loading = ref.watch(authProvider).isLoading || usageAsync.isLoading;
|
|
|
|
// 周期聚合:取最后 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);
|
|
}
|
|
|
|
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),
|
|
];
|
|
}
|
|
|
|
// 折线:最后 14 天每日总流量(GB) + 对应日期(X 轴标注)。
|
|
final chartPts = usage.length > 14 ? usage.sublist(usage.length - 14) : usage;
|
|
final chartVals = chartPts.map((p) => p.gbTotal).toList();
|
|
final chartDates = chartPts.map((p) => p.date).toList();
|
|
|
|
final body = loading
|
|
? Center(child: CircularProgressIndicator(color: c.accent, strokeWidth: 2.6))
|
|
: RefreshIndicator(
|
|
color: c.accent,
|
|
backgroundColor: c.surface,
|
|
onRefresh: () async {
|
|
ref.invalidate(usageProvider(usageKey));
|
|
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(t: t),
|
|
]),
|
|
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: chartVals,
|
|
dates: chartDates,
|
|
accent: c.accent,
|
|
grid: c.border,
|
|
axis: c.fg3,
|
|
surface: c.surface,
|
|
),
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (isWide) return body;
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
AppTopBar(brand: t.brand),
|
|
PageTitle(title: t.statsTitle),
|
|
Expanded(child: body),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// 字节 → (数值, 单位):<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');
|
|
}
|
|
|
|
/// 统计页选中的设备(null = 全部;否则 device uuid)。第②层(按设备过滤卡/图)
|
|
/// 待 per-device 每日曲线 API(#10);本层先把下拉内容接成真实设备。
|
|
final statsDeviceProvider = StateProvider<String?>((ref) => null);
|
|
|
|
/// 设备下拉(右上角):「全部」+ 用户**已登录的真实设备**(来自 devicesProvider,
|
|
/// 即 GET /v1/me/devices)。选某台 → 卡片/折线只算那台(#10②)。
|
|
class _DeviceDropdown extends ConsumerWidget {
|
|
const _DeviceDropdown({required this.t});
|
|
final AppText t;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final c = context.pangolin;
|
|
final devices = ref.watch(devicesProvider).valueOrNull ?? const <Device>[];
|
|
final selected = ref.watch(statsDeviceProvider);
|
|
String nameOf(String uuid) {
|
|
final d = devices.where((e) => e.uuid == uuid);
|
|
return d.isEmpty ? '' : (d.first.name.isNotEmpty ? d.first.name : d.first.platform);
|
|
}
|
|
|
|
final label = selected == null
|
|
? t.allDevices
|
|
: (nameOf(selected).isNotEmpty ? nameOf(selected) : t.allDevices);
|
|
|
|
return PopupMenuButton<String>(
|
|
tooltip: '',
|
|
offset: const Offset(0, 38),
|
|
color: c.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
onSelected: (v) => ref.read(statsDeviceProvider.notifier).state = v.isEmpty ? null : v,
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(value: '', child: Text(t.allDevices, style: PangolinText.sm.copyWith(color: c.fg1))),
|
|
for (final d in devices)
|
|
PopupMenuItem(
|
|
value: d.uuid,
|
|
child: Text(d.name.isNotEmpty ? d.name : d.platform, 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),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|