diff --git a/client/lib/bridge/log_time.dart b/client/lib/bridge/log_time.dart deleted file mode 100644 index 867d0f1..0000000 --- a/client/lib/bridge/log_time.dart +++ /dev/null @@ -1,8 +0,0 @@ -// log_time.dart — 桌面端日志统一时间戳前缀。 -// 返回本地时间 "[HH:MM:SS.mmm] ",拼在日志行首便于排查时序。 - -String logTime() { - final n = DateTime.now(); - String p(int v, [int w = 2]) => v.toString().padLeft(w, '0'); - return '[${p(n.hour)}:${p(n.minute)}:${p(n.second)}.${p(n.millisecond, 3)}] '; -} diff --git a/client/lib/models/node.dart b/client/lib/models/node.dart index b04f032..95ff67d 100644 --- a/client/lib/models/node.dart +++ b/client/lib/models/node.dart @@ -14,10 +14,11 @@ class Node { this.uuid = '', this.tier = 'free', this.tag = NodeTag.none, + this.host = '', + this.port = 0, }); /// 服务端 UUID,用于 POST /v1/nodes/{uuid}/connect。 - /// 演示节点为空字符串。 final String uuid; /// 2 字母国家码(HK/JP/SG…),界面以码块渲染,绝不用 emoji 国旗。 @@ -28,10 +29,29 @@ class Node { /// 节点层级:'free' | 'pro'。 final String tier; - /// 演示延迟(ms);真实节点由探针数据填充。 + /// 客户端实测延迟(ms);0 = 尚未测得(显示 — )。 final int ping; final NodeTag tag; + /// 节点入口 host/port(来自 /v1/nodes),用于客户端 TCP 实测延迟。 + final String host; + final int port; + + /// 延迟标签:测得显示「Nms」,未测显示「—」。 + String get pingLabel => ping > 0 ? '${ping}ms' : '—'; + + Node copyWith({int? ping}) => Node( + code: code, + nameZh: nameZh, + nameEn: nameEn, + ping: ping ?? this.ping, + uuid: uuid, + tier: tier, + tag: tag, + host: host, + port: port, + ); + String localizedName(AppLang lang) => lang == AppLang.zh ? nameZh : nameEn; /// 副标题:有标签时显示语义文字,否则用拉丁地名(两语言通用,不串语言)。 @@ -50,14 +70,5 @@ class Node { /// 智能选择的哨兵选中值。 const String kSmartNodeCode = 'AUTO'; -/// 演示节点清单(对齐 design/ui_kits/mobile/parts.jsx 的 SERVERS)。 -const List kDemoNodes = [ - Node(code: 'HK', nameZh: '香港 · 流媒体', nameEn: 'Hong Kong', ping: 18, tag: NodeTag.streaming), - Node(code: 'JP', nameZh: '日本 东京', nameEn: 'Tokyo', ping: 32, tag: NodeTag.p2p), - Node(code: 'SG', nameZh: '新加坡', nameEn: 'Singapore', ping: 54, tag: NodeTag.p2p), - Node(code: 'TW', nameZh: '台湾 台北', nameEn: 'Taipei', ping: 28), - Node(code: 'US', nameZh: '美国 洛杉矶', nameEn: 'Los Angeles', ping: 146, tag: NodeTag.streaming), - Node(code: 'DE', nameZh: '德国 法兰克福', nameEn: 'Frankfurt', ping: 198), - Node(code: 'UK', nameZh: '英国 伦敦', nameEn: 'London', ping: 210), - Node(code: 'KR', nameZh: '韩国 首尔', nameEn: 'Seoul', ping: 41), -]; +/// 节点未就绪时的中性占位(非伪造服务器,仅加载窗口短暂展示)。 +const Node kPlaceholderNode = Node(code: '··', nameZh: '加载中', nameEn: 'Loading', ping: 0); diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 0fe230b..1b581b2 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -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( diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index 00381cd..3c9b060 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState { 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 []); 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), ]), diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index 1fb46dc..47fd7d1 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -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 []; + final node = ref.watch(effectiveNodeProvider); + + final weekly = (me?.weeklyGb.length == 7) ? me!.weeklyGb : List.filled(7, 0.0); + final maxV = weekly.fold(0, (a, b) => a > b ? a : b); + final denom = maxV <= 0 ? 1.0 : maxV; + final monthGb = usage.fold(0, (s, p) => s + p.gbTotal); + final monthHours = usage.fold(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)), diff --git a/client/lib/services/latency_probe.dart b/client/lib/services/latency_probe.dart new file mode 100644 index 0000000..61478bd --- /dev/null +++ b/client/lib/services/latency_probe.dart @@ -0,0 +1,42 @@ +// latency_probe.dart — 客户端实测节点延迟。 +// +// per-client 到节点的 RTT 服务端无法代知(agent 到控制面的 RTT ≠ 用户 ping), +// 故由客户端对各节点入口做并行 TCP 握手计时,得到真实 per-client 延迟。 +// 取多次取最小(规避抖动),失败返回 0(UI 显示 — )。 +import 'dart:async'; +import 'dart:io'; + +/// 对 [host]:[port] 做 [samples] 次 TCP 握手,返回最小耗时(ms);全失败返回 0。 +Future probeLatency( + String host, + int port, { + int samples = 2, + Duration timeout = const Duration(seconds: 3), +}) async { + if (host.isEmpty || port <= 0) return 0; + int best = 0; + for (var i = 0; i < samples; i++) { + final sw = Stopwatch()..start(); + try { + final socket = await Socket.connect(host, port, timeout: timeout); + sw.stop(); + socket.destroy(); + final ms = sw.elapsedMilliseconds; + if (best == 0 || ms < best) best = ms; + } catch (_) { + // 单次失败忽略,继续下一次采样。 + } + } + return best; +} + +/// 并行测一组 (uuid, host, port),返回 uuid→延迟(ms)。 +Future> probeAll( + Iterable<({String uuid, String host, int port})> targets, +) async { + final entries = await Future.wait(targets.map((tg) async { + final ms = await probeLatency(tg.host, tg.port); + return MapEntry(tg.uuid, ms); + })); + return Map.fromEntries(entries); +} diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart index 8d68333..a1c1887 100644 --- a/client/lib/state/account_providers.dart +++ b/client/lib/state/account_providers.dart @@ -5,6 +5,7 @@ // 在后续阶段加入本文件。 import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/device.dart'; import '../models/me.dart'; import '../models/plan.dart'; import '../models/usage_point.dart'; @@ -58,3 +59,22 @@ final usageProvider = FutureProvider.family, int>((ref, days) a if (!ref.watch(authProvider).isLoggedIn) return const []; return ref.read(accountApiProvider).usage(days: days); }); + +/// 已登录设备列表(GET /v1/me/devices)。 +class DevicesNotifier extends AsyncNotifier> { + @override + Future> build() async { + if (!ref.watch(authProvider).isLoggedIn) return const []; + return ref.read(accountApiProvider).devices(); + } + + /// 移除设备(DELETE)后刷新列表。 + Future remove(String uuid) async { + await ref.read(accountApiProvider).removeDevice(uuid); + ref.invalidateSelf(); + await future; + } +} + +final devicesProvider = + AsyncNotifierProvider>(DevicesNotifier.new); diff --git a/client/lib/state/nodes_provider.dart b/client/lib/state/nodes_provider.dart index eb0064e..75a243f 100644 --- a/client/lib/state/nodes_provider.dart +++ b/client/lib/state/nodes_provider.dart @@ -1,49 +1,65 @@ -// nodes_provider.dart — 节点清单 + 当前选择 +// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟 // -// 从 GET /v1/nodes 拉取节点列表;认证后自动刷新。 -// 未登录 / 加载中时退回演示节点(kDemoNodes)保证 UI 正常显示。 +// 从 GET /v1/nodes 拉取真实节点(含 host/port);拉取后后台对各节点做 TCP 握手 +// 实测 per-client 延迟并回填。未登录 / 失败返回空列表(不再伪造演示节点)。 +import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import '../models/node.dart'; +import '../services/api_config.dart'; +import '../services/latency_probe.dart'; import 'auth_provider.dart'; -// ── API base URL(由 --dart-define 注入)────────────────────────── - -const _kApiUrl = String.fromEnvironment( - 'PANGOLIN_API_URL', - defaultValue: 'http://localhost:8080', -); - // ── 节点列表 AsyncNotifier ──────────────────────────────────────── class NodesNotifier extends AsyncNotifier> { + bool _disposed = false; + @override Future> build() async { + ref.onDispose(() => _disposed = true); final auth = ref.watch(authProvider); - if (!auth.isLoggedIn) return kDemoNodes; - return _fetchNodes(auth.accessToken!); + if (!auth.isLoggedIn) return const []; + final list = await _fetchNodes(auth.accessToken!); + unawaited(_measure(list)); + return list; } Future refresh() async { - state = const AsyncLoading(); final auth = ref.read(authProvider); if (!auth.isLoggedIn) { - state = AsyncData(kDemoNodes); + state = const AsyncData([]); return; } - state = await AsyncValue.guard(() => _fetchNodes(auth.accessToken!)); + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final list = await _fetchNodes(auth.accessToken!); + unawaited(_measure(list)); + return list; + }); + } + + /// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。 + Future _measure(List list) async { + if (list.isEmpty) return; + final pings = await probeAll( + [for (final n in list) (uuid: n.uuid, host: n.host, port: n.port)], + ); + if (_disposed) return; + final updated = [for (final n in list) n.copyWith(ping: pings[n.uuid] ?? n.ping)]; + state = AsyncData(updated); } static Future> _fetchNodes(String accessToken) async { - final uri = Uri.parse('$_kApiUrl/v1/nodes'); + final uri = Uri.parse('$kApiBaseUrl/v1/nodes'); final resp = await http.get(uri, headers: { 'Authorization': 'Bearer $accessToken', }).timeout(const Duration(seconds: 10)); - if (resp.statusCode != 200) return kDemoNodes; + if (resp.statusCode != 200) return const []; final body = jsonDecode(resp.body) as Map; final rawList = body['nodes'] as List? ?? []; @@ -55,7 +71,9 @@ class NodesNotifier extends AsyncNotifier> { nameZh: m['name_zh'] as String? ?? '', nameEn: m['name_en'] as String? ?? '', tier: m['tier'] as String? ?? 'free', - ping: 0, // 延迟由探针数据填充;MVP 默认 0 + host: m['host'] as String? ?? '', + port: (m['port'] as num?)?.toInt() ?? 0, + ping: 0, // 由 _measure 实测回填 ); }).toList(); } @@ -64,7 +82,7 @@ class NodesNotifier extends AsyncNotifier> { final nodesProvider = AsyncNotifierProvider>(NodesNotifier.new); -// ── 当前选中的节点 UUID;'AUTO' 表示智能选择(默认)───────────────── +// ── 当前选中的节点 code;'AUTO' 表示智能选择(默认)───────────────── final selectedNodeCodeProvider = StateProvider((ref) => kSmartNodeCode); @@ -73,16 +91,18 @@ final isSmartSelectProvider = Provider( (ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode, ); -/// 实际生效的节点:同步拉取 AsyncValue;未就绪时取 kDemoNodes 第一条。 +/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。 final effectiveNodeProvider = Provider((ref) { - final nodesAsync = ref.watch(nodesProvider); - final nodes = nodesAsync.valueOrNull ?? kDemoNodes; - if (nodes.isEmpty) return kDemoNodes.first; + final nodes = ref.watch(nodesProvider).valueOrNull ?? const []; + if (nodes.isEmpty) return kPlaceholderNode; final code = ref.watch(selectedNodeCodeProvider); if (code == kSmartNodeCode) { - // 延迟最小者;MVP 无真实延迟时取第一条 - return nodes.reduce((a, b) => a.ping <= b.ping ? a : b); + return nodes.reduce((a, b) { + final pa = a.ping > 0 ? a.ping : 1 << 30; + final pb = b.ping > 0 ? b.ping : 1 << 30; + return pa <= pb ? a : b; + }); } return nodes.firstWhere((n) => n.code == code, orElse: () => nodes.first); }); diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index ebf1a2e..52fc56e 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -3,9 +3,13 @@ // 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。 // App 内无任何支付表单——购买仅引导至外部渠道。 import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; +import '../models/device.dart'; import '../pangolin_theme.dart'; +import '../services/auth_api.dart'; +import '../state/account_providers.dart'; import 'pangolin_button.dart'; import 'pangolin_icons.dart'; import 'plan_card.dart'; @@ -43,133 +47,149 @@ class _SubScaffold extends StatelessWidget { } } -/// ── 套餐选择 ── -class PlansScreen extends StatelessWidget { +/// ── 套餐选择 ── (价格/额度来自 GET /v1/plans;当前档由 me.plan 判定; +/// 功能清单仍取 l10n,后端无此字段) +class PlansScreen extends ConsumerWidget { const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false}); final AppText t; final ValueChanged? onChoose; final VoidCallback? onBack; final bool embedded; + List _feats(String code) => switch (code) { + 'free' => t.featsFree, + 'pro' => t.featsPro, + 'team' => t.featsTeam, + _ => const [], + }; + @override - Widget build(BuildContext context) { - final plans = [ - (id: 'free', name: t.freePlan, price: '¥0', cta: t.current, featured: false, current: true, pop: null as String?, feats: t.featsFree), - (id: 'pro', name: t.proPlan, price: '¥25', cta: t.upgrade, featured: true, current: false, pop: t.mostPopular, feats: t.featsPro), - (id: 'team', name: t.teamPlan, price: '¥99', cta: t.choose, featured: false, current: false, pop: null as String?, feats: t.featsTeam), - ]; - return _SubScaffold( - title: t.choosePlan, - onBack: onBack, - embedded: embedded, - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), - children: [ - for (final p in plans) - Padding( - padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0), - child: PlanCard( - name: p.name, price: p.price, period: t.perMonth, - features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current, - popularLabel: p.pop, onPressed: () => onChoose?.call(p.id), - ), - ), - ], - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final plansAsync = ref.watch(plansProvider); + final myPlan = ref.watch(meProvider).valueOrNull?.plan ?? 'free'; + + Widget body() => plansAsync.when( + loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), + error: (_, __) => Center( + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), + data: (plans) => ListView( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + children: [ + for (final p in plans) + Padding( + padding: EdgeInsets.only(bottom: 14, top: p.code == 'pro' ? 12 : 0), + child: PlanCard( + name: p.localizedName(t.lang), + price: p.priceLabel(), + period: t.perMonth, + features: _feats(p.code), + ctaLabel: p.code == myPlan ? t.current : (p.code == 'team' ? t.choose : t.upgrade), + featured: p.code == 'pro', + isCurrent: p.code == myPlan, + popularLabel: p.code == 'pro' ? t.mostPopular : null, + onPressed: () => onChoose?.call(p.code), + ), + ), + ], + ), + ); + + return _SubScaffold(title: t.choosePlan, onBack: onBack, embedded: embedded, child: body()); } } -/// ── 设备管理 ── -class DeviceItem { - const DeviceItem({required this.id, required this.icon, required this.name, required this.os, required this.active, this.me = false}); - final String id, name, os, active; - final IconData icon; - final bool me; -} - -class DevicesScreen extends StatefulWidget { +/// ── 设备管理 ── (真实 GET /v1/me/devices;移除 = DELETE。本机高亮待 P6 设备 id) +class DevicesScreen extends ConsumerWidget { const DevicesScreen({super.key, required this.t, this.onBack, this.embedded = false}); final AppText t; final VoidCallback? onBack; final bool embedded; - @override - State createState() => _DevicesScreenState(); -} -class _DevicesScreenState extends State { - late List _devices = [ - DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.t.thisDevice, me: true), - DeviceItem(id: '2', icon: PangolinIcons.smartphone, name: 'iPhone 17', os: 'iOS 26', active: '2 min'), - DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.t.lang == AppLang.zh ? '3 小时前' : '3h ago'), - ]; + IconData _icon(String platform) => switch (platform.toLowerCase()) { + 'ios' || 'android' => PangolinIcons.smartphone, + 'macos' || 'windows' || 'linux' => PangolinIcons.laptop, + _ => PangolinIcons.monitorSmartphone, + }; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - return _SubScaffold( - title: widget.t.myDevices, - onBack: widget.onBack, - embedded: widget.embedded, - child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ - Text(widget.t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - const SizedBox(height: 12), - Container( - decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), - clipBehavior: Clip.antiAlias, - child: Column(children: [ - for (var i = 0; i < _devices.length; i++) _row(c, _devices[i], i < _devices.length - 1), - ]), - ), - ]), - ); + String _lastSeen(DateTime? ts) { + final zh = t.lang == AppLang.zh; + if (ts == null) return zh ? '从未在线' : 'never'; + final d = DateTime.now().difference(ts.toLocal()); + if (d.inMinutes < 1) return zh ? '刚刚' : 'just now'; + if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago'; + if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago'; + return zh ? '${d.inDays} 天前' : '${d.inDays}d ago'; } - Widget _row(PangolinScheme c, DeviceItem d, bool divider) { + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final devicesAsync = ref.watch(devicesProvider); + + Widget content() => devicesAsync.when( + loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), + error: (_, __) => Center( + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), + data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ + Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + const SizedBox(height: 12), + if (devices.isEmpty) + Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet', + style: PangolinText.sm.copyWith(color: c.fg3)) + else + Container( + decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + for (var i = 0; i < devices.length; i++) _row(ref, c, devices[i], i < devices.length - 1), + ]), + ), + ]), + ); + + return _SubScaffold(title: t.myDevices, onBack: onBack, embedded: embedded, child: content()); + } + + Widget _row(WidgetRef ref, PangolinScheme c, Device d, bool divider) { + final name = d.name.isNotEmpty ? d.name : d.platform; return Container( decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row(children: [ Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), - child: Icon(d.icon, size: 19, color: c.accent)), + child: Icon(_icon(d.platform), size: 19, color: c.accent)), const SizedBox(width: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Flexible(child: Text(d.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))), - if (d.me) - Container( - margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), - decoration: BoxDecoration(color: c.successSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5)), - ), - ]), + Text(name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)), const SizedBox(height: 1), - Text(d.me ? d.os : '${d.os} · ${d.active}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + Text('${d.platform} · ${_lastSeen(d.lastSeen)}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), ])), - if (!d.me) - OutlinedButton( - onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)), - style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero), - child: Text(widget.t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)), - ), + OutlinedButton( + onPressed: () => ref.read(devicesProvider.notifier).remove(d.uuid), + style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero), + child: Text(t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)), + ), ]), ); } } -/// ── 兑换 & 购买 ── -class RedeemScreen extends StatefulWidget { +/// ── 兑换 & 购买 ── (兑换码走 POST /v1/redeem,成功后刷新账户) +class RedeemScreen extends ConsumerStatefulWidget { const RedeemScreen({super.key, required this.t, this.onBack, this.embedded = false}); final AppText t; final VoidCallback? onBack; final bool embedded; @override - State createState() => _RedeemScreenState(); + ConsumerState createState() => _RedeemScreenState(); } -class _RedeemScreenState extends State { +class _RedeemScreenState extends ConsumerState { final _code = TextEditingController(); bool _ok = false; + bool _busy = false; + String? _err; @override void dispose() { @@ -177,6 +197,25 @@ class _RedeemScreenState extends State { super.dispose(); } + Future _redeem() async { + final code = _code.text.trim(); + if (code.isEmpty) return; + setState(() { + _busy = true; + _err = null; + }); + try { + await ref.read(accountApiProvider).redeem(code); + // 订阅变化 → 刷新账户聚合,套餐横幅/额度随之更新。 + await ref.read(meProvider.notifier).refresh(); + if (mounted) setState(() => _ok = true); + } on AuthApiException catch (e) { + if (mounted) setState(() => _err = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn); + } finally { + if (mounted) setState(() => _busy = false); + } + } + @override Widget build(BuildContext context) { final c = context.pangolin; @@ -218,8 +257,12 @@ class _RedeemScreenState extends State { ), )), const SizedBox(width: 10), - PangolinButton(label: t.redeemBtn, onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null), + PangolinButton(label: t.redeemBtn, onPressed: (_busy || _code.text.trim().length < 4) ? null : _redeem), ]), + if (_err != null) ...[ + const SizedBox(height: 10), + Text(_err!, style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)), + ], ]), ), const SizedBox(height: 22), diff --git a/client/lib/widgets/server_tile.dart b/client/lib/widgets/server_tile.dart index 28e979c..72999b8 100644 --- a/client/lib/widgets/server_tile.dart +++ b/client/lib/widgets/server_tile.dart @@ -65,7 +65,7 @@ class ServerTile extends StatelessWidget { ], ), ), - 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(width: 8), SignalBars(ping: node.ping), if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)], diff --git a/client/test/golden/goldens/desktop_devices.png b/client/test/golden/goldens/desktop_devices.png index 609f15e..00031a5 100644 Binary files a/client/test/golden/goldens/desktop_devices.png and b/client/test/golden/goldens/desktop_devices.png differ diff --git a/client/test/golden/goldens/desktop_plans.png b/client/test/golden/goldens/desktop_plans.png index d795048..341ea3b 100644 Binary files a/client/test/golden/goldens/desktop_plans.png and b/client/test/golden/goldens/desktop_plans.png differ diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png index 0e206c1..f258ef4 100644 Binary files a/client/test/golden/goldens/desktop_servers.png and b/client/test/golden/goldens/desktop_servers.png differ diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index ed899a3..36165d0 100644 Binary files a/client/test/golden/goldens/desktop_stats.png and b/client/test/golden/goldens/desktop_stats.png differ diff --git a/client/test/unit/nodes_provider_test.dart b/client/test/unit/nodes_provider_test.dart index 975379e..33db39f 100644 --- a/client/test/unit/nodes_provider_test.dart +++ b/client/test/unit/nodes_provider_test.dart @@ -25,13 +25,23 @@ class _NullTokenStore implements TokenStore { Future isOnboarded() async => true; } -// ── 辅助:带 stub 覆盖的 ProviderContainer ────────────────────── +// 直接喂固定节点列表(不触发真实探针/HTTP)。 +class _FakeNodes extends NodesNotifier { + _FakeNodes(this.list); + final List list; + @override + Future> build() async => list; +} + +const _nodes = [ + Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 30, uuid: 'hk'), + Node(code: 'JP', nameZh: '日本', nameEn: 'Tokyo', ping: 12, uuid: 'jp'), + Node(code: 'US', nameZh: '美国', nameEn: 'LA', ping: 200, uuid: 'us'), +]; ProviderContainer makeContainer({List overrides = const []}) => ProviderContainer(overrides: [ tokenStoreProvider.overrideWithValue(const _NullTokenStore()), - // effectiveNodeProvider 在 nodesProvider 加载时退回 kDemoNodes, - // 所以测试直接读 effectiveNodeProvider,无需等待 nodesProvider.future。 ...overrides, ]); @@ -43,21 +53,31 @@ void main() { expect(c.read(isSmartSelectProvider), true); }); - test('智能选择取延迟最小节点(退回 kDemoNodes)', () { + test('无节点时 effectiveNode 返回占位(不再伪造演示节点)', () { final c = makeContainer(); addTearDown(c.dispose); - // nodesProvider 加载中时退回 kDemoNodes,effectiveNodeProvider 立即可用。 - final node = c.read(effectiveNodeProvider); - final minPing = kDemoNodes.map((n) => n.ping).reduce((a, b) => a < b ? a : b); - expect(node.ping, minPing); + expect(c.read(effectiveNodeProvider).code, kPlaceholderNode.code); }); - test('选中具体节点后生效节点随之改变', () { - final c = makeContainer(); + test('智能选择取实测延迟最小节点', () async { + final c = makeContainer(overrides: [nodesProvider.overrideWith(() => _FakeNodes(_nodes))]); addTearDown(c.dispose); - c.read(selectedNodeCodeProvider.notifier).state = 'JP'; + await c.read(nodesProvider.future); + expect(c.read(effectiveNodeProvider).code, 'JP'); // ping 12 最小 + }); + + test('选中具体节点后生效节点随之改变', () async { + final c = makeContainer(overrides: [nodesProvider.overrideWith(() => _FakeNodes(_nodes))]); + addTearDown(c.dispose); + await c.read(nodesProvider.future); + c.read(selectedNodeCodeProvider.notifier).state = 'US'; expect(c.read(isSmartSelectProvider), false); - expect(c.read(effectiveNodeProvider).code, 'JP'); + expect(c.read(effectiveNodeProvider).code, 'US'); + }); + + test('pingLabel:未测得显示 —', () { + expect(const Node(code: 'X', nameZh: 'x', nameEn: 'x', ping: 0).pingLabel, '—'); + expect(const Node(code: 'X', nameZh: 'x', nameEn: 'x', ping: 42).pingLabel, '42ms'); }); test('localizedSub 不串语言:无标签用拉丁地名', () {