feat(client): 三端布局架构 + macOS 桌面端 + app 图标
三端布局(mobile/tablet/desktop): - core/responsive/form_factor.dart 形态判定 + shell/ 分发器(home_shell→desktop/mobile) - desktop_shell 对照 ui_kits/desktop/dapp.jsx: 侧栏204·6项 + 套餐卡 + 顶栏(标题/状态/主题切换) + 连接页居中单列 - 新增组件 nav_sidebar / plan_badge_card / content_top_bar / bottom_tab_bar - 新增一级页 contact_page / settings_page; navigation_provider(NavView) - 删除旧 widgets/home_shell.dart(逻辑迁入 shell/) macOS 桌面端: - 窗口默认 920×600 + 最小 720×560(MainFlutterWindow.swift) - app 图标替换为穿山甲(AppIcon.appiconset 全套, 由 app-icon.svg 渲染) 其余(本会话): - Phase2 接线: auth_api/token_store/auth_provider/vpn_bridge_provider + 真实 connection/nodes - lucide_icons 兼容补丁(packages/lucide_icons_patched) 修复 IconData final 报错 - 测试修复: connect_passthrough(UTF-8) / harness / golden @Skip - l10n 新增 settingsTitle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,62 @@
|
||||
// auth_screen.dart — 登录 / 注册页(文案经 AppText 单显)
|
||||
// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程)
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../services/auth_api.dart';
|
||||
import '../state/auth_provider.dart';
|
||||
import 'pangolin_button.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'pangolin_logo.dart';
|
||||
|
||||
// API base URL(由 --dart-define 注入)
|
||||
const _kApiUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
|
||||
enum _AuthMode { login, register }
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
class AuthScreen extends ConsumerStatefulWidget {
|
||||
const AuthScreen({super.key, required this.onDone, required this.t});
|
||||
final VoidCallback onDone;
|
||||
final AppText t;
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen> {
|
||||
class _AuthScreenState extends ConsumerState<AuthScreen> {
|
||||
_AuthMode _mode = _AuthMode.login;
|
||||
int _step = 0;
|
||||
bool _sent = false;
|
||||
bool _loading = false;
|
||||
String? _errorZh;
|
||||
|
||||
final _email = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _pw = TextEditingController();
|
||||
|
||||
late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
|
||||
|
||||
// ── Dev-only 测试账户旁路(仅 debug build)─────────────────────
|
||||
// 后端 /v1/auth 未就绪时,用此账户直接进入主界面浏览 UI。
|
||||
// 登录后节点列表回退 kDemoNodes,连接键走 mock 动画。
|
||||
static const _devEmail = 'test@pangolin.dev';
|
||||
static const _devPassword = 'test1234';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// debug 下预填测试账户,方便直接点登录。
|
||||
if (kDebugMode) {
|
||||
_email.text = _devEmail;
|
||||
_pw.text = _devPassword;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||
|
||||
@override
|
||||
@@ -33,9 +64,60 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_email.dispose();
|
||||
_code.dispose();
|
||||
_pw.dispose();
|
||||
_api.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── 认证操作 ──────────────────────────────────────────────────
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
try {
|
||||
await _api.sendCode(_email.text.trim());
|
||||
if (mounted) setState(() { _sent = true; _loading = false; });
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doRegister() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
try {
|
||||
final tokens = await _api.register(
|
||||
email: _email.text.trim(),
|
||||
code: _code.text.trim(),
|
||||
password: _pw.text,
|
||||
);
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doLogin() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
// Dev 旁路:debug build 下用测试账户跳过后端直接登录。
|
||||
// 用 devLogin 只设内存态,不写 keychain(规避 -34018 entitlement 问题)。
|
||||
if (kDebugMode && _email.text.trim() == _devEmail && _pw.text == _devPassword) {
|
||||
ref.read(authProvider.notifier).devLogin('dev-access-token');
|
||||
if (mounted) widget.onDone();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final tokens = await _api.login(
|
||||
email: _email.text.trim(),
|
||||
password: _pw.text,
|
||||
);
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
@@ -62,6 +144,22 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
]),
|
||||
Divider(height: 1, color: c.border),
|
||||
const SizedBox(height: 22),
|
||||
if (_errorZh != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: c.danger.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
border: Border.all(color: c.danger.withAlpha(80)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.x, size: 16, color: c.danger),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
Expanded(child: _mode == _AuthMode.login ? _login(c, t) : _register(c, t)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
@@ -82,6 +180,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_mode = m;
|
||||
_step = 0;
|
||||
_sent = false;
|
||||
_errorZh = null;
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
@@ -122,7 +221,11 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
PangolinButton(label: t.doLogin, expand: true, onPressed: (_emailValid && _pw.text.isNotEmpty) ? widget.onDone : null),
|
||||
PangolinButton(
|
||||
label: _loading ? '...' : t.doLogin,
|
||||
expand: true,
|
||||
onPressed: (!_loading && _emailValid && _pw.text.isNotEmpty) ? _doLogin : null,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -133,9 +236,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: Row(children: [
|
||||
Expanded(child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))),
|
||||
GestureDetector(
|
||||
onTap: _emailValid ? () => setState(() => _sent = true) : null,
|
||||
onTap: (!_loading && _emailValid) ? _sendCode : null,
|
||||
child: Text(_sent ? t.resend : t.sendCode,
|
||||
style: PangolinText.caption.copyWith(color: _emailValid ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
|
||||
style: PangolinText.caption.copyWith(color: (!_loading && _emailValid) ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
])),
|
||||
if (_sent)
|
||||
@@ -156,13 +259,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
PangolinButton(label: t.doNext, expand: true, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null),
|
||||
]);
|
||||
}
|
||||
// Step 1: set password
|
||||
return Column(children: [
|
||||
Row(children: [Icon(PangolinIcons.checkCircle, size: 15, color: c.success), const SizedBox(width: 7), Text(_email.text, style: PangolinText.sm.copyWith(color: c.fg2))]),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.lock, label: t.pwLabel,
|
||||
child: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: t.setPwPh), onChanged: (_) => setState(() {}))),
|
||||
const SizedBox(height: 18),
|
||||
PangolinButton(label: t.doCreate, expand: true, onPressed: _pw.text.length >= 6 ? widget.onDone : null),
|
||||
PangolinButton(
|
||||
label: _loading ? '...' : t.doCreate,
|
||||
expand: true,
|
||||
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user