Files
pangolin/client/lib/widgets/home_shell.dart
T
wangjia bd8f974a25 feat(M6): 控制面联调 — mock connect server + 客户端 ConnectApi 透传
tsk_nuoKSM4Vt-zK

## 服务端(server/cmd/mockserver)
- `main.go`(79 行):独立 mock HTTP server,实现 POST /v1/nodes/:id/connect
  - 路径/请求体/响应体字段名与 §3.1 契约逐字一致
  - Bearer token 鉴权(-token 参数,空值跳过,不入库)
  - 缺少 device_id → 400;未授权 → 401;非 POST → 405
  - 返回完整 sing-box config JSON(tun + REALITY/Hy2 outbound + urltest + route/dns)
  - 注:mock 联调;待 #5/#6 真实 connect 接口就绪后替换
- `main_test.go`:6 项单测,覆盖 §3.1 结构校验、auth、method、path

## 客户端(client/)
- `lib/services/connect_api.dart`:ConnectApi 类
  - fetchConfig(nodeId, deviceId) → 原始响应体字符串(不做任何修改)
  - 错误路径:HTTP 非 200 / 超时 / 非法 JSON → ConnectApiException(含双语 message)
- `lib/services/vpn_bridge.dart`:VpnBridge stub(M6 联调,libbox 绑定待 11C)
  - start(configJson) 原样存储,不修改;stop() 清空
- `lib/widgets/home_shell.dart`:_toggle/_pick 替换为真实 API 流程
  - ConnectApi.fetchConfig → VpnBridge.start(透传,无中间变换)
  - 错误路径:ConnectApiException → SnackBar,status 回 off,无残留半开隧道
  - API URL/token/deviceId 由 --dart-define 注入(不入库)
- `lib/widgets/server_tile.dart`:ServerInfo 新增 nodeId 字段
- `pubspec.yaml`:新增 http: ^1.2.1 依赖
- `test/connect_passthrough_test.dart`:4 项透传断言单测
  - 核心:fetchConfig 返回字符串 === VpnBridge.start 接收字符串(逐字节相等)
  - 空格/格式原样保留(不经 jsonEncode 重序列化)
  - HTTP 错误、非法 JSON 错误路径覆盖

## 运行方式(M6 联调)
```
# 启动 mock server(无 auth)
go run ./server/cmd/mockserver -addr :8081

# 启动客户端(指向 mock server)
flutter run \
  --dart-define=PANGOLIN_API_URL=http://localhost:8081 \
  --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \
  --dart-define=PANGOLIN_DEVICE_ID=demo-device-001
```

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:16:29 +08:00

632 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// home_shell.dart — 主框架(底部 4 Tab:连接 / 节点 / 统计 / 账户)
//
// M6 改动:_toggle/_pick 替换为真实 ConnectApi.fetchConfig → VpnBridge.start。
// API URL / token 由 --dart-define 注入(见 README M6 联调说明),默认指向本地 mock server。
// tsk_nuoKSM4Vt-zK
import 'dart:async';
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import '../services/connect_api.dart';
import '../services/vpn_bridge.dart';
import 'pangolin_icons.dart';
import 'connect_button.dart';
import 'server_tile.dart';
import 'country_code.dart';
import 'account_screens.dart';
import 'pangolin_logo.dart';
// ── 联调配置(由 --dart-define 注入;不入库)──
// 运行示例:flutter run --dart-define=PANGOLIN_API_URL=http://localhost:8081 \
// --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \
// --dart-define=PANGOLIN_DEVICE_ID=demo-device-001
const _kApiUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'http://localhost:8081',
);
const _kApiToken = String.fromEnvironment(
'PANGOLIN_API_TOKEN',
defaultValue: 'dev-mock-token',
);
const _kDeviceId = String.fromEnvironment(
'PANGOLIN_DEVICE_ID',
defaultValue: 'demo-device-001',
);
class HomeShell extends StatefulWidget {
const HomeShell({super.key, this.zh = true});
final bool zh;
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _tab = 0;
VpnStatus _status = VpnStatus.off;
ServerInfo _server = const ServerInfo(
code: 'HK',
name: '香港 · 流媒体',
sub: 'Hong Kong',
ping: 18,
nodeId: 'hk-1',
);
Timer? _timer;
int _elapsed = 0;
// ── 控制面 + 数据面 ──
late final ConnectApi _api = ConnectApi(baseUrl: _kApiUrl, authToken: _kApiToken);
final VpnBridge _bridge = VpnBridge();
String _t(String zh, String en) => widget.zh ? zh : en;
// ── 连接/断开(异步,含错误路径)──
void _toggle() {
if (_status == VpnStatus.connecting) return; // 防重入
if (_status == VpnStatus.off) {
_connect(_server);
} else {
_disconnect();
}
}
Future<void> _connect(ServerInfo server) async {
setState(() => _status = VpnStatus.connecting);
final messenger = ScaffoldMessenger.of(context);
try {
// 1. 从控制面拉取完整 sing-box config JSON
final configJson = await _api.fetchConfig(
nodeId: server.effectiveNodeId,
deviceId: _kDeviceId,
);
// 2. 原样透传给 VpnBridge.start(禁止在 Dart 层修改 configJson
await _bridge.start(configJson);
if (!mounted) return;
setState(() {
_status = VpnStatus.on;
_elapsed = 0;
});
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => setState(() => _elapsed++),
);
} on ConnectApiException catch (e) {
if (!mounted) return;
setState(() => _status = VpnStatus.off);
messenger.showSnackBar(SnackBar(
content: Text(widget.zh ? e.messageZh : e.messageEn),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating,
));
} catch (e) {
if (!mounted) return;
setState(() => _status = VpnStatus.off);
messenger.showSnackBar(SnackBar(
content: Text(_t('连接失败,请稍后重试', 'Connection failed, please try again')),
behavior: SnackBarBehavior.floating,
));
}
}
Future<void> _disconnect() async {
_timer?.cancel();
setState(() => _status = VpnStatus.off);
await _bridge.stop();
}
void _pick(ServerInfo s) {
setState(() {
_server = s;
_tab = 0;
});
if (_status == VpnStatus.on) {
_timer?.cancel();
_connect(s);
}
}
@override
void dispose() {
_timer?.cancel();
_api.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final pages = [
_ConnectPage(
zh: widget.zh,
status: _status,
server: _server,
elapsedSec: _elapsed,
onToggle: _toggle,
onOpenServers: () => setState(() => _tab = 1),
),
_ServersPage(zh: widget.zh, current: _server.code, onPick: _pick),
_StatsPage(zh: widget.zh),
_AccountPage(zh: widget.zh),
];
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(bottom: false, child: pages[_tab]),
bottomNavigationBar: _BottomTab(
zh: widget.zh,
index: _tab,
onTap: (i) => setState(() => _tab = i),
),
);
}
}
/// ── Bottom tab bar ──
class _BottomTab extends StatelessWidget {
const _BottomTab({required this.zh, required this.index, required this.onTap});
final bool zh;
final int index;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final items = [
(PangolinIcons.power, zh ? '连接' : 'Connect'),
(PangolinIcons.globe, zh ? '节点' : 'Servers'),
(PangolinIcons.barChart, zh ? '统计' : 'Stats'),
(PangolinIcons.user, zh ? '账户' : 'Account'),
];
return Container(
decoration: BoxDecoration(
color: c.surface,
border: Border(top: BorderSide(color: c.border)),
),
padding: const EdgeInsets.only(top: 8, bottom: 22),
child: Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(i),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3),
const SizedBox(height: 4),
Text(
items[i].$2,
style: PangolinText.caption.copyWith(
color: i == index ? c.accent : c.fg3,
fontWeight: i == index ? FontWeight.w700 : FontWeight.w500,
),
),
]),
),
),
],
),
);
}
}
/// ── Connect page ──
class _ConnectPage extends StatelessWidget {
const _ConnectPage({
required this.zh,
required this.status,
required this.server,
required this.elapsedSec,
required this.onToggle,
required this.onOpenServers,
});
final bool zh;
final VpnStatus status;
final ServerInfo server;
final int elapsedSec;
final VoidCallback onToggle, onOpenServers;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final cap = switch (status) {
VpnStatus.off => zh ? '未连接 · 轻点连接' : 'Tap to connect',
VpnStatus.connecting => zh ? '连接中…' : 'Connecting…',
VpnStatus.on => zh ? '已连接 · 网络已加密' : 'Connected · Encrypted',
};
return Column(children: [
_TopBar(
zh: zh,
trailing: Text(
status == VpnStatus.on
? (zh ? '● 在线' : '● Online')
: (zh ? '○ 离线' : '○ Offline'),
style: PangolinText.mono.copyWith(
fontSize: 12,
color: status == VpnStatus.on ? c.success : c.fg3,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ConnectButton(
status: status,
elapsed: Duration(seconds: elapsedSec),
onTap: onToggle,
),
const SizedBox(height: 24),
Text(
cap,
style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600),
),
]),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
child: GestureDetector(
onTap: onOpenServers,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Row(children: [
CountryCode(code: server.code, active: true),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
server.name,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600),
),
Text(
'${server.sub} · ${server.ping}ms',
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400),
),
]),
),
Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3),
]),
),
),
),
]);
}
}
/// ── Servers page ──
class _ServersPage extends StatelessWidget {
const _ServersPage({required this.zh, required this.current, required this.onPick});
final bool zh;
final String current;
final ValueChanged<ServerInfo> onPick;
static const _servers = [
ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18, nodeId: 'hk-1'),
ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32, nodeId: 'jp-1'),
ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28, nodeId: 'tw-1'),
ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54, nodeId: 'sg-1'),
ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41, nodeId: 'kr-1'),
ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146, nodeId: 'us-1'),
];
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
zh ? '选择节点' : 'Choose server',
style: PangolinText.h2.copyWith(color: c.fg1),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: _servers.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ServerTile(
server: _servers[i],
active: _servers[i].code == current,
onTap: () => onPick(_servers[i]),
),
),
),
),
]);
}
}
/// ── Stats page ──
class _StatsPage extends StatelessWidget {
const _StatsPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
final labels = zh ? ['', '', '', '', '', '', ''] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const maxV = 6.1;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
for (final m in [
(zh ? '本月流量' : 'Traffic', '42.6', 'GB'),
(zh ? '平均延迟' : 'Ping', '29', 'ms'),
(zh ? '本月时长' : 'Time', '86.4', 'h'),
])
Expanded(
child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(14),
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: [
Text(
m.$1,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text.rich(TextSpan(
text: m.$2,
style: PangolinText.mono
.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
children: [
TextSpan(
text: ' ${m.$3}',
style: PangolinText.caption.copyWith(color: c.fg3),
)
],
)),
]),
),
),
]),
),
const SizedBox(height: 16),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
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: [
Text(
zh ? '本周流量 (GB)' : 'This week (GB)',
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 18),
Expanded(
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: 12, color: c.fg3),
),
const SizedBox(height: 6),
Container(
height: 90 * (vals[i] / maxV),
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: c.accent.withOpacity(.85),
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
),
),
const SizedBox(height: 6),
Text(labels[i], style: PangolinText.caption.copyWith(color: c.fg3)),
]),
),
]),
),
]),
),
),
),
]);
}
}
/// ── Account page ──
class _AccountPage extends StatelessWidget {
const _AccountPage({required this.zh});
final bool zh;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_TopBar(zh: zh),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 14),
child: Text(zh ? '我的' : 'Account', style: PangolinText.h2.copyWith(color: c.fg1)),
),
Expanded(
child: ListView(padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [PangolinColors.clay600, PangolinColors.clay800],
),
borderRadius: BorderRadius.circular(PangolinRadius.xl),
boxShadow: PangolinShadow.md,
),
child: Row(children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: PangolinColors.white.withOpacity(.18),
shape: BoxShape.circle,
),
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white),
),
const SizedBox(width: 11),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text(
'me@pangolin.vpn',
style: TextStyle(
color: PangolinColors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
zh ? 'PRO 会员 · 至 2026-12-31' : 'PRO · until 2026-12-31',
style: TextStyle(color: PangolinColors.white.withOpacity(.85), fontSize: 12),
),
]),
),
FilledButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PlansScreen(
zh: zh,
onChoose: (_) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)),
),
),
)),
style: FilledButton.styleFrom(
backgroundColor: PangolinColors.white,
foregroundColor: PangolinColors.clay700,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: Text(
zh ? '续费 / 升级' : 'Renew',
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
),
),
]),
),
const SizedBox(height: 18),
_accountRow(c, PangolinIcons.mail, zh ? '邮箱' : 'Email', 'me@pangolin.vpn'),
_accountRow(c, PangolinIcons.lock, zh ? '密码' : 'Password', '••••••••••'),
_accountRow(
c,
PangolinIcons.monitorSmartphone,
zh ? '我的设备' : 'My devices',
'',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DevicesScreen(zh: zh)),
),
),
_accountRow(
c,
PangolinIcons.shoppingBag,
zh ? '兑换 & 购买' : 'Redeem & buy',
'',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)),
),
),
_accountRow(
c,
PangolinIcons.messageCircle,
zh ? '联系我们' : 'Contact us',
'',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ContactScreen(zh: zh)),
),
),
_accountRow(
c,
PangolinIcons.logOut,
zh ? '退出登录' : 'Sign out',
'',
danger: true,
),
]),
),
]);
}
Widget _accountRow(
PangolinScheme c,
IconData icon,
String title,
String value, {
bool danger = false,
VoidCallback? onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Row(children: [
Icon(icon, size: 20, color: danger ? c.danger : c.accent),
const SizedBox(width: 13),
Expanded(
child: Text(
title,
style: PangolinText.body
.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500),
),
),
if (value.isNotEmpty)
Text(value, style: PangolinText.sm.copyWith(color: c.fg3))
else
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
),
);
}
}
/// ── Shared top bar ──
class _TopBar extends StatelessWidget {
const _TopBar({required this.zh, this.trailing});
final bool zh;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 14),
child: Row(children: [
PangolinBrandLockup(zh: zh, markSize: 24),
const Spacer(),
if (trailing != null) trailing!,
]),
);
}
}