diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index a29711f..00381cd 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -52,8 +52,9 @@ class _NodesPageState extends ConsumerState { final search = _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v)); if (widget.isWide) { + // 对照 dapp.jsx DServers:padding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。 return ListView( - padding: const EdgeInsets.fromLTRB(28, 6, 28, 24), + padding: const EdgeInsets.fromLTRB(28, 4, 28, 24), children: [ smartCard, const SizedBox(height: 14), @@ -63,8 +64,8 @@ class _NodesPageState extends ConsumerState { crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 11, - crossAxisSpacing: 11, + mainAxisSpacing: 10, + crossAxisSpacing: 10, childAspectRatio: 4.4, children: [ for (final n in nodes) diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index dabec61..1fb46dc 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -17,7 +17,8 @@ class StatsPage extends ConsumerWidget { final c = context.pangolin; final t = ref.watch(appTextProvider); final maxV = _vals.reduce((a, b) => a > b ? a : b); - final pad = isWide ? 28.0 : 20.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'), @@ -26,20 +27,20 @@ class StatsPage extends ConsumerWidget { ]; final body = ListView( - padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24), + 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 ? 12 : 0), + 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: 16), + const SizedBox(height: 20), Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), @@ -47,25 +48,29 @@ class StatsPage extends ConsumerWidget { boxShadow: PangolinShadow.sm, ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(t.weekTraffic, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), + Text(t.weekTraffic, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 13)), const SizedBox(height: 18), SizedBox( - height: isWide ? 150 : 120, + // 柱最高 86 + 上下数值/星期标签两行 + 间距 ≈ 134,留余量 140 避免溢出。 + height: 140, child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ for (var i = 0; i < _vals.length; i++) Expanded( child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ - Text('${_vals[i]}', style: PangolinText.mono.copyWith(fontSize: 11, color: c.fg3)), - const SizedBox(height: 6), - Container( - height: (isWide ? 104 : 84) * (_vals[i] / maxV), - margin: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - color: c.accent.withOpacity(0.85), - borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), + Text('${_vals[i]}', 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), + decoration: BoxDecoration( + color: c.accent.withOpacity(0.85), + borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), + ), ), ), - const SizedBox(height: 6), + const SizedBox(height: 8), Text(t.days7[i], style: PangolinText.caption.copyWith(color: c.fg3)), ]), ), @@ -92,7 +97,7 @@ class _MetricCard extends StatelessWidget { Widget build(BuildContext context) { final c = context.pangolin; return Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), @@ -101,10 +106,10 @@ class _MetricCard extends StatelessWidget { ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), + const SizedBox(height: 7), Text.rich(TextSpan( text: value, - style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500), + style: PangolinText.mono.copyWith(fontSize: 24, color: c.fg1, fontWeight: FontWeight.w500), children: [TextSpan(text: ' $unit', style: PangolinText.caption.copyWith(color: c.fg3))], )), ]), diff --git a/client/test/golden/desktop_pages_golden_test.dart b/client/test/golden/desktop_pages_golden_test.dart new file mode 100644 index 0000000..3af5657 --- /dev/null +++ b/client/test/golden/desktop_pages_golden_test.dart @@ -0,0 +1,62 @@ +// desktop_pages_golden_test.dart — desktop 整页 golden(节点/统计/账户) +// +// 用 debugDefaultTargetPlatformOverride=macOS + 920×600 surface 让 context.formFactor +// 判定为 desktop,渲染 DesktopShell 各页,固化精还原成果作回归闸。 +// 连接页含连接键无限旋转动画,单独由组件级 golden 覆盖,此处不截。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/shell/desktop_shell.dart'; +import 'package:pangolin_vpn/shell/home_shell.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/state/navigation_provider.dart'; + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} +} + +Future _shoot(WidgetTester tester, NavView view, String name) async { + // 平台覆盖须在 test 体内设置并在结束前复位(否则触发 foundation 调试变量未复位断言)。 + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + tester.view.physicalSize = const Size(920, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + navViewProvider.overrideWith((ref) => view), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const HomeShell(), + ), + )); + await tester.pumpAndSettle(); + await expectLater(find.byType(DesktopShell), matchesGoldenFile('goldens/$name.png')); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +void main() { + testWidgets('desktop 节点页', (t) => _shoot(t, NavView.servers, 'desktop_servers')); + testWidgets('desktop 统计页', (t) => _shoot(t, NavView.stats, 'desktop_stats')); + testWidgets('desktop 账户页', (t) => _shoot(t, NavView.account, 'desktop_account')); +} diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png new file mode 100644 index 0000000..024b923 Binary files /dev/null and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png new file mode 100644 index 0000000..0e206c1 Binary files /dev/null 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 new file mode 100644 index 0000000..ed899a3 Binary files /dev/null and b/client/test/golden/goldens/desktop_stats.png differ