From aee9ba7b72743397ef242914f5ea29febc53f00a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 06:28:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E7=99=BB=E5=BD=95=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=20=E2=80=94=20=E8=AE=B0=E4=BD=8F=E9=82=AE=E7=AE=B1=20?= =?UTF-8?q?+=20=E5=AF=86=E7=A0=81=E6=98=BE=E9=9A=90=20+=207=E5=A4=A9?= =?UTF-8?q?=E5=85=8D=E7=99=BB=E9=99=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 记住邮箱:登录页预填上次邮箱(TokenStore.saveLastEmail,登录/注册成功时存, 退出登录不清除)。 2. 密码显隐:登录/注册密码框加眼睛按钮切换明文(PangolinIcons.eye/eyeOff)。 3. 7天免登陆:RootFlow 改为响应 authProvider——启动时有有效会话直接进主界面; AuthNotifier.refresh() 在服务端拒绝(refresh 过期/超 7 天)时 logout 回登录页, 网络错误则保留会话。会话恢复期间显示极简启动屏。 flutter analyze 0 error;114 tests passed。 Co-Authored-By: Claude Opus 4.8 --- client/lib/main.dart | 40 ++++++++++++------- client/lib/services/token_store.dart | 5 +++ client/lib/state/auth_provider.dart | 5 +++ client/lib/widgets/auth_screen.dart | 39 ++++++++++++++++-- client/lib/widgets/pangolin_icons.dart | 2 + .../golden/desktop_pages_golden_test.dart | 4 ++ .../test/unit/connection_controller_test.dart | 4 ++ client/test/unit/nodes_provider_test.dart | 4 ++ client/test/unit/quota_controller_test.dart | 4 ++ 9 files changed, 89 insertions(+), 18 deletions(-) diff --git a/client/lib/main.dart b/client/lib/main.dart index fe188c3..1b41297 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -7,8 +7,10 @@ import 'pangolin_theme.dart'; import 'services/token_store.dart'; import 'shell/home_shell.dart'; import 'state/app_providers.dart'; +import 'state/auth_provider.dart'; import 'widgets/auth_screen.dart'; import 'widgets/onboarding_screen.dart'; +import 'widgets/pangolin_logo.dart'; void main() => runApp(const ProviderScope(child: PangolinApp())); @@ -29,9 +31,8 @@ class PangolinApp extends ConsumerWidget { } } -/// 应用阶段:登录 → 引导 → 主应用 -enum AppStage { auth, onboarding, app } - +/// 应用根流转:启动时若已有有效会话(7 天内 refresh 有效)直接进主界面, +/// 免登陆;会话失效则回登录页。引导页仅首次注册/登录后看一次。 class RootFlow extends ConsumerStatefulWidget { const RootFlow({super.key}); @override @@ -39,32 +40,43 @@ class RootFlow extends ConsumerStatefulWidget { } class _RootFlowState extends ConsumerState { - AppStage stage = AppStage.auth; final _store = const TokenStore(); + bool _forceOnboarding = false; - // 登录后:引导只首次看过一次;之后直接进主界面。 + // 登录/注册成功后:未看过引导才进引导,否则直接主界面。 Future _afterAuth() async { final onboarded = await _store.isOnboarded(); if (!mounted) return; - setState(() => stage = onboarded ? AppStage.app : AppStage.onboarding); + setState(() => _forceOnboarding = !onboarded); } Future _afterOnboarding() async { await _store.markOnboarded(); if (!mounted) return; - setState(() => stage = AppStage.app); + setState(() => _forceOnboarding = false); } @override Widget build(BuildContext context) { final t = ref.watch(appTextProvider); - switch (stage) { - case AppStage.auth: - return AuthScreen(t: t, onDone: () { _afterAuth(); }); - case AppStage.onboarding: - return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); }); - case AppStage.app: - return const HomeShell(); + final auth = ref.watch(authProvider); + + if (auth.isLoading) return const _Splash(); + if (!auth.isLoggedIn) { + return AuthScreen(t: t, onDone: () { _afterAuth(); }); } + if (_forceOnboarding) { + return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); }); + } + return const HomeShell(); } } + +/// 会话恢复期间的极简启动屏(避免登录页一闪而过)。 +class _Splash extends StatelessWidget { + const _Splash(); + @override + Widget build(BuildContext context) => const Scaffold( + body: Center(child: PangolinMark(size: 56)), + ); +} diff --git a/client/lib/services/token_store.dart b/client/lib/services/token_store.dart index e46d5ea..06da52f 100644 --- a/client/lib/services/token_store.dart +++ b/client/lib/services/token_store.dart @@ -14,6 +14,7 @@ class TokenStore { static const _kAccess = 'pangolin_access_token'; static const _kRefresh = 'pangolin_refresh_token'; static const _kOnboarded = 'pangolin_onboarded'; + static const _kLastEmail = 'pangolin_last_email'; Future saveTokens({ required String access, @@ -34,4 +35,8 @@ class TokenStore { // 首次引导标记:引导页只在第一次看(看完置位,后续登录直接进主界面)。 Future markOnboarded() => _storage.write(key: _kOnboarded, value: '1'); Future isOnboarded() async => (await _storage.read(key: _kOnboarded)) == '1'; + + // 记住上次登录邮箱(登录页预填;退出登录不清除)。 + Future saveLastEmail(String email) => _storage.write(key: _kLastEmail, value: email); + Future loadLastEmail() => _storage.read(key: _kLastEmail); } diff --git a/client/lib/state/auth_provider.dart b/client/lib/state/auth_provider.dart index 141568f..7886385 100644 --- a/client/lib/state/auth_provider.dart +++ b/client/lib/state/auth_provider.dart @@ -60,6 +60,11 @@ class AuthNotifier extends StateNotifier { try { await saveTokens(await _api.refresh(rt)); return true; + } on AuthApiException catch (e) { + // 服务端明确拒绝(refresh 过期/失效,超出 7 天)→ 退出登录回登录页; + // 网络错误(statusCode<=0)则保留会话,稍后重试。 + if (e.statusCode > 0) await logout(); + return false; } catch (_) { return false; } diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index 9abc1e4..eb977ab 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -32,6 +32,7 @@ class _AuthScreenState extends ConsumerState { int _step = 0; bool _sent = false; bool _loading = false; + bool _pwVisible = false; String? _errorZh; final _email = TextEditingController(); @@ -42,6 +43,17 @@ class _AuthScreenState extends ConsumerState { bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text); + @override + void initState() { + super.initState(); + // 预填上次登录邮箱。 + ref.read(tokenStoreProvider).loadLastEmail().then((email) { + if (email != null && email.isNotEmpty && mounted) { + setState(() => _email.text = email); + } + }); + } + @override void dispose() { _email.dispose(); @@ -71,6 +83,7 @@ class _AuthScreenState extends ConsumerState { code: _code.text.trim(), password: _pw.text, ); + await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); if (mounted) widget.onDone(); } on AuthApiException catch (e) { @@ -85,6 +98,7 @@ class _AuthScreenState extends ConsumerState { email: _email.text.trim(), password: _pw.text, ); + await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); if (mounted) widget.onDone(); } on AuthApiException catch (e) { @@ -182,13 +196,31 @@ class _AuthScreenState extends ConsumerState { InputDecoration get _bare => const InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(vertical: 12)); + // 密码输入 + 显示/隐藏切换(眼睛按钮)。 + Widget _pwInput(PangolinScheme c, String hint) => Row(children: [ + Expanded( + child: TextField( + controller: _pw, + obscureText: !_pwVisible, + decoration: _bare.copyWith(hintText: hint), + onChanged: (_) => setState(() {}), + ), + ), + GestureDetector( + onTap: () => setState(() => _pwVisible = !_pwVisible), + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: Icon(_pwVisible ? PangolinIcons.eyeOff : PangolinIcons.eye, size: 18, color: c.fg3), + ), + ), + ]); + Widget _login(PangolinScheme c, AppText t) { return Column(children: [ _field(c, icon: PangolinIcons.mail, label: t.emailLabel, child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))), const SizedBox(height: 16), - _field(c, icon: PangolinIcons.lock, label: t.pwLabel, - child: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: t.pwPh), onChanged: (_) => setState(() {}))), + _field(c, icon: PangolinIcons.lock, label: t.pwLabel, child: _pwInput(c, t.pwPh)), Align( alignment: Alignment.centerRight, child: Padding( @@ -239,8 +271,7 @@ class _AuthScreenState extends ConsumerState { 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(() {}))), + _field(c, icon: PangolinIcons.lock, label: t.pwLabel, child: _pwInput(c, t.setPwPh)), const SizedBox(height: 18), PangolinButton( label: _loading ? '...' : t.doCreate, diff --git a/client/lib/widgets/pangolin_icons.dart b/client/lib/widgets/pangolin_icons.dart index 3aa42c2..a3dfe75 100644 --- a/client/lib/widgets/pangolin_icons.dart +++ b/client/lib/widgets/pangolin_icons.dart @@ -18,6 +18,8 @@ class PangolinIcons { // ── 导航 / 操作 ── static const settings = LucideIcons.settings; + static const eye = LucideIcons.eye; + static const eyeOff = LucideIcons.eyeOff; static const user = LucideIcons.user; static const search = LucideIcons.search; static const check = LucideIcons.check; diff --git a/client/test/golden/desktop_pages_golden_test.dart b/client/test/golden/desktop_pages_golden_test.dart index 528f91c..53385c9 100644 --- a/client/test/golden/desktop_pages_golden_test.dart +++ b/client/test/golden/desktop_pages_golden_test.dart @@ -30,6 +30,10 @@ class _NullTokenStore implements TokenStore { Future markOnboarded() async {} @override Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; } Future _shoot(WidgetTester tester, NavView view, String name) async { diff --git a/client/test/unit/connection_controller_test.dart b/client/test/unit/connection_controller_test.dart index 25d2812..fa8ae7d 100644 --- a/client/test/unit/connection_controller_test.dart +++ b/client/test/unit/connection_controller_test.dart @@ -29,6 +29,10 @@ class _NullTokenStore implements TokenStore { Future markOnboarded() async {} @override Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; } // ── 辅助 ────────────────────────────────────────────────────────── diff --git a/client/test/unit/nodes_provider_test.dart b/client/test/unit/nodes_provider_test.dart index 33db39f..8ed9a2e 100644 --- a/client/test/unit/nodes_provider_test.dart +++ b/client/test/unit/nodes_provider_test.dart @@ -23,6 +23,10 @@ class _NullTokenStore implements TokenStore { Future markOnboarded() async {} @override Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; } // 直接喂固定节点列表(不触发真实探针/HTTP)。 diff --git a/client/test/unit/quota_controller_test.dart b/client/test/unit/quota_controller_test.dart index 0ab7b59..e0da4b7 100644 --- a/client/test/unit/quota_controller_test.dart +++ b/client/test/unit/quota_controller_test.dart @@ -20,6 +20,10 @@ class _NullTokenStore implements TokenStore { Future markOnboarded() async {} @override Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; } ProviderContainer _container() => ProviderContainer(