diff --git a/client/lib/shell/home_shell.dart b/client/lib/shell/home_shell.dart index 4bcfc12..ad4e098 100644 --- a/client/lib/shell/home_shell.dart +++ b/client/lib/shell/home_shell.dart @@ -10,6 +10,7 @@ import '../core/responsive/form_factor.dart'; import '../pangolin_theme.dart'; import 'desktop_shell.dart'; import 'mobile_shell.dart'; +import 'tablet_shell.dart'; class HomeShell extends ConsumerWidget { const HomeShell({super.key}); @@ -19,7 +20,7 @@ class HomeShell extends ConsumerWidget { final c = context.pangolin; final Widget body = switch (context.formFactor) { FormFactor.desktop => const DesktopShell(), - FormFactor.tablet => const MobileShell(), // TODO: TabletShell + FormFactor.tablet => const TabletShell(), FormFactor.mobile => const MobileShell(), }; return Scaffold(backgroundColor: c.bg, body: body); diff --git a/client/lib/shell/tablet_shell.dart b/client/lib/shell/tablet_shell.dart new file mode 100644 index 0000000..88a824f --- /dev/null +++ b/client/lib/shell/tablet_shell.dart @@ -0,0 +1,70 @@ +// tablet_shell.dart — 平板外壳(侧栏 4 项触控 + 内容区),对照 ui_kits/tablet/tabapp.jsx +// +// 与 desktop 区别:侧栏 232 触控尺寸、4 项(无联系/设置)、顶栏无主题切换; +// 连接页自动走双栏(connect_page 在 formFactor==tablet 时进 isWide 分支)。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../pangolin_theme.dart'; +import '../screens/account_page.dart'; +import '../screens/connect_page.dart'; +import '../screens/nodes_page.dart'; +import '../screens/stats_page.dart'; +import '../state/app_providers.dart'; +import '../state/navigation_provider.dart'; +import '../widgets/content_top_bar.dart'; +import '../widgets/nav_sidebar.dart'; +import '../widgets/pangolin_icons.dart'; + +class TabletShell extends ConsumerWidget { + const TabletShell({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final t = ref.watch(appTextProvider); + final view = ref.watch(navViewProvider); + + final items = [ + (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), + (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), + (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.user, label: t.tabMe, view: NavView.account), + ]; + + final titles = { + NavView.connect: t.tabConnect, + NavView.servers: t.tabServers, + NavView.stats: t.tabStats, + NavView.account: t.tabMe, + }; + + void go(NavView v) => ref.read(navViewProvider.notifier).state = v; + + Widget content() { + switch (view) { + case NavView.connect: + return ConnectPage(isWide: true, onOpenNodes: () => go(NavView.servers)); + case NavView.servers: + return NodesPage(isWide: true, onPicked: () => go(NavView.connect)); + case NavView.stats: + return const StatsPage(isWide: true); + default: // account / contact / settings / plans / redeem → 账户(tablet 一级只 4 项) + return const AccountPage(isWide: true); + } + } + + return ColoredBox( + color: c.bg, + child: Row(children: [ + NavSidebar(items: items, width: 232, dense: false), + Expanded( + child: Column(children: [ + ContentTopBar(title: titles[view] ?? t.tabMe, showThemeToggle: false, titleSize: 19), + Expanded(child: content()), + ]), + ), + ]), + ); + } +} diff --git a/client/lib/widgets/content_top_bar.dart b/client/lib/widgets/content_top_bar.dart index 6a21a25..39a63c7 100644 --- a/client/lib/widgets/content_top_bar.dart +++ b/client/lib/widgets/content_top_bar.dart @@ -12,10 +12,12 @@ import '../state/nodes_provider.dart'; import 'pangolin_icons.dart'; class ContentTopBar extends ConsumerWidget { - const ContentTopBar({super.key, required this.title, this.onBack}); + const ContentTopBar({super.key, required this.title, this.onBack, this.showThemeToggle = true, this.titleSize = 17}); final String title; final VoidCallback? onBack; + final bool showThemeToggle; + final double titleSize; @override Widget build(BuildContext context, WidgetRef ref) { @@ -40,7 +42,7 @@ class ContentTopBar extends ConsumerWidget { const SizedBox(width: 4), ], Text(title, - style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 17)), + style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: titleSize)), const Spacer(), Text( conn.phase == VpnPhase.on ? '● ${node.code}' : '○', @@ -50,16 +52,18 @@ class ContentTopBar extends ConsumerWidget { color: conn.phase == VpnPhase.on ? c.success : c.fg3, ), ), - const SizedBox(width: 12), - InkWell( - onTap: () => ref.read(themeModeProvider.notifier).state = - isDark ? ThemeMode.light : ThemeMode.dark, - borderRadius: BorderRadius.circular(PangolinRadius.sm), - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(isDark ? PangolinIcons.sun : PangolinIcons.moon, size: 18, color: c.fg2), + if (showThemeToggle) ...[ + const SizedBox(width: 12), + InkWell( + onTap: () => ref.read(themeModeProvider.notifier).state = + isDark ? ThemeMode.light : ThemeMode.dark, + borderRadius: BorderRadius.circular(PangolinRadius.sm), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(isDark ? PangolinIcons.sun : PangolinIcons.moon, size: 18, color: c.fg2), + ), ), - ), + ], ]), ); } diff --git a/client/lib/widgets/nav_sidebar.dart b/client/lib/widgets/nav_sidebar.dart index 15de2c7..83e310b 100644 --- a/client/lib/widgets/nav_sidebar.dart +++ b/client/lib/widgets/nav_sidebar.dart @@ -15,11 +15,14 @@ import 'plan_badge_card.dart'; typedef NavSidebarItem = ({IconData icon, String label, NavView view}); class NavSidebar extends ConsumerWidget { - const NavSidebar({super.key, required this.items, this.width = 204}); + const NavSidebar({super.key, required this.items, this.width = 204, this.dense = true}); final List items; final double width; + /// dense=true 桌面紧凑(行高~36);dense=false 平板触控(行高≥48, 字号更大)。 + final bool dense; + @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; @@ -32,17 +35,18 @@ class NavSidebar extends ConsumerWidget { color: c.bgSubtle, border: Border(right: BorderSide(color: c.border)), ), - padding: const EdgeInsets.fromLTRB(12, 14, 12, 14), + padding: EdgeInsets.fromLTRB(dense ? 12 : 14, dense ? 14 : 18, dense ? 12 : 14, dense ? 14 : 16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 品牌区 Padding( - padding: const EdgeInsets.fromLTRB(6, 2, 6, 16), + padding: EdgeInsets.fromLTRB(6, 2, 6, dense ? 16 : 20), child: Row(children: [ - const PangolinMark(size: 28), - const SizedBox(width: 9), + PangolinMark(size: dense ? 28 : 30), + SizedBox(width: dense ? 9 : 10), Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(t.brand, - style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 16, height: 1)), + style: PangolinText.h3.copyWith( + color: c.fg1, fontWeight: FontWeight.w700, fontSize: dense ? 16 : 17, height: 1)), const SizedBox(height: 3), Text('PANGOLIN', style: PangolinText.caption.copyWith( @@ -53,11 +57,12 @@ class NavSidebar extends ConsumerWidget { // Nav 列表 for (final item in items) Padding( - padding: const EdgeInsets.only(bottom: 3), + padding: EdgeInsets.only(bottom: dense ? 3 : 4), child: _NavItem( icon: item.icon, label: item.label, active: current == item.view, + dense: dense, onTap: () => ref.read(navViewProvider.notifier).state = item.view, ), ), @@ -69,11 +74,12 @@ class NavSidebar extends ConsumerWidget { } class _NavItem extends StatelessWidget { - const _NavItem({required this.icon, required this.label, required this.active, required this.onTap}); + const _NavItem({required this.icon, required this.label, required this.active, required this.onTap, this.dense = true}); final IconData icon; final String label; final bool active; final VoidCallback onTap; + final bool dense; @override Widget build(BuildContext context) { @@ -84,17 +90,20 @@ class _NavItem extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), - child: Row(children: [ - Icon(icon, size: 18, color: active ? c.accent : c.fg3), - const SizedBox(width: 11), - Text(label, - style: PangolinText.sm.copyWith( - color: active ? c.accent : c.fg2, - fontSize: 13.5, - fontWeight: active ? FontWeight.w600 : FontWeight.w500)), - ]), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: dense ? 0 : 48), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: dense ? 12 : 14, vertical: dense ? 9 : 13), + child: Row(children: [ + Icon(icon, size: dense ? 18 : 20, color: active ? c.accent : c.fg3), + SizedBox(width: dense ? 11 : 12), + Text(label, + style: PangolinText.sm.copyWith( + color: active ? c.accent : c.fg2, + fontSize: dense ? 13.5 : 15, + fontWeight: active ? (dense ? FontWeight.w600 : FontWeight.w700) : FontWeight.w500)), + ]), + ), ), ), ); diff --git a/client/tool/visual-diff.md b/client/tool/visual-diff.md new file mode 100644 index 0000000..3c38665 --- /dev/null +++ b/client/tool/visual-diff.md @@ -0,0 +1,44 @@ +# 视觉 diff 工作流(pangolin 客户端 ↔ design/ui_kits 原型) + +用 `design-distill` skill 的截图 diff 工具,把 Flutter 实现与 `design/ui_kits/{desktop,tablet,mobile}` React 原型对照。 + +## 工具 +- 原型截图:`~/.claude/skills/design-distill/tools/shoot-prototype.mjs` +- 像素 diff:`~/.claude/skills/design-distill/tools/diff.mjs` + +## 原型基准(已验证可用) +从项目根运行(脚本自动起临时 http server,让 Babel 原型能 fetch jsx): + +```bash +SHOOT=~/.claude/skills/design-distill/tools/shoot-prototype.mjs +# desktop 连接页 明/暗(920×600 内容,截外层 sand 画布给点余量) +node $SHOOT design/ui_kits/desktop/index.html shots/desktop_connect_light.png \ + --width 1000 --height 720 --theme light --lang zh --wait-for "#root > *" +node $SHOOT design/ui_kits/desktop/index.html shots/desktop_connect_dark.png \ + --width 1000 --height 720 --theme dark --lang zh --wait-for "#root > *" +``` +> 原型是 SPA,默认显示连接页。截其它页需给 shoot-prototype 加 `--click `(待补)。 + +## diff +```bash +node ~/.claude/skills/design-distill/tools/diff.mjs --threshold 0.05 +``` + +## 实现侧截图:两条路径与现状 + +### A. Flutter golden(推荐,CI 友好)— 当前被 google_fonts 阻塞 +`flutter test --update-goldens` 截 `RepaintBoundary`,无 chrome/窗口问题,可入 CI。 +**阻塞**:组件用 `GoogleFonts.*()`,测试环境 `allowRuntimeFetching=false` 会抛异常(`test/golden/components_golden_test.dart` 因此 `@Skip`)。 +**解法**:让字体可注入——把 `pangolin_theme.dart` 的 `PangolinText` / `GoogleFonts.*TextTheme` 改为可由测试用 `FontLoader` 注册的 family 覆盖(test/fonts/ 已备 Manrope/Sora/JetBrainsMono ttf),或测试态走 bundled 字体。改完即可启用 golden 做严格回归闸。 + +### B. screencapture 真实 app(已可截,但受限) +真实渲染、字体真实,但:macOS 原生标题栏(红绿灯) vs 原型自绘 chrome 结构不同;窗口精确定位需「辅助功能」权限。 +**用途**:作为**人工视觉对照**(生成 diff 图定位结构偏差),非严格像素闸。 + +### C. iOS 模拟器(最干净的全屏,未接) +`xcrun simctl io booted screenshot` 截模拟器无桌面 chrome、字体真实,适合 mobile/tablet。导航多页仍需 integration_test 驱动。 + +## 建议 +- **严格回归闸** → 走 A,先做 `PangolinText` 字体可注入(一次性小重构),启用 golden。 +- **对照设计原型** → 原型基准(本文档)+ A 的 golden 截图,用 diff.mjs 比对。 +- desktop 因 chrome 差异,像素级 100% 对齐不是目标;以**布局/间距/颜色结构一致**为验收口径。