feat(client): tablet 外壳 + 视觉 diff 工作流文档

- tablet_shell: 侧栏 232·4 项触控(minHeight 48, 字号 15) + 顶栏无主题切换 + 连接页双栏
  对照 ui_kits/tablet/tabapp.jsx;home_shell 分发 tablet→TabletShell
- nav_sidebar 加 dense 参数(desktop 紧凑 / tablet 触控)
- content_top_bar 加 showThemeToggle + titleSize 参数
- tool/visual-diff.md: 截图 diff 工作流(design-distill 工具)+ 实现侧三路径
  现状(golden 被 google_fonts 阻塞 / screencapture 受 macOS chrome 限制)+ 推荐

注: tablet 形态在 macOS 桌面平台不触发(只 desktop/mobile),运行验证需 iPad。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-16 09:38:18 +08:00
parent 4dc4127252
commit 281a6261fb
5 changed files with 159 additions and 31 deletions
+2 -1
View File
@@ -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);
+70
View File
@@ -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 = <NavSidebarItem>[
(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, String>{
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()),
]),
),
]),
);
}
}
+15 -11
View File
@@ -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),
),
),
),
],
]),
);
}
+28 -19
View File
@@ -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<NavSidebarItem> 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)),
]),
),
),
),
);
+44
View File
@@ -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 <navSelector>`(待补)。
## diff
```bash
node ~/.claude/skills/design-distill/tools/diff.mjs <baseline.png> <impl.png> <diff.png> --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% 对齐不是目标;以**布局/间距/颜色结构一致**为验收口径。