import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/storage/user_storage.dart'; import 'package:new_learn/core/api/auth/api.dart'; /// ============================================================ /// 【LoginPage】登录页 —— 对照 Vue 版 pages/index.vue 移植 /// /// 一、布局结构(自上而下): /// 1. 渐变头部 + 欢迎语(原 Vue 是背景图 + welcome 图) /// 2. 账号输入框(圆角灰底 + 左侧图标 + 右侧历史下拉箭头) /// 3. 密码输入框(左侧图标 + 右侧"眼睛"显隐切换) /// 4. 记住密码 / 忘记密码 /// 5. 立即登录按钮(loading 态防重复提交) /// 6. 协议勾选 + 《用户协议》《隐私政策》入口 /// /// 二、登录流程(对齐 Vue logInNow): /// 校验账号/密码 → 校验协议 → 调 UserApi.login(密码 MD5 加密) /// → 成功: /// ① token 写进 UserStorage(安全存储)+ DioClient.accessToken(内存,拦截器自动带) /// ② UserApi.getCurrentUserExtension 拉全量用户信息 → 缓存到 UserStorage /// → 失败:ApiException.message 直接 Toast,永不接触 Dio 细节 /// /// 三、与 Vue 的对应关系: /// uni.showToast → ScaffoldMessenger SnackBar /// formLogin (ref 对象) → _accountController/_passwordController /// stringToMd5(password) → UserApi.login 内部已处理(md5) /// app.setToken / storage → UserStorage.saveToken(flutter_secure_storage) /// app.fetchCurrentUserExtension → UserApi.getCurrentUserExtension /// 用户协议/隐私政策 → 空方法(占位,本页先不做跳转) /// /// 四、高频坑: /// 1. TextEditingController 必须在 dispose 里释放(防内存泄漏); /// 2. 密码框 obscureText 切换要 setState 重建,否则不生效; /// 3. 登录按钮 loading 时禁用,防止连点发多个请求; /// 4. 页面里用了 .w/.h/.sp/.r 就不能加 const(运行期才知道尺寸)。 /// ============================================================ class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override ConsumerState createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState { // ---------- 表单控制器(dispose 里必须释放) ---------- final TextEditingController _accountController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); bool _obscurePassword = true; // 密码是否打码 bool _rememberPassword = true; // 记住密码 bool _hasAgreed = false; // 已同意协议 bool _logging = false; // 登录请求中(按钮 loading) /// 登录历史(对齐 Vue loginHistory,本版只存内存,不落盘) final List _loginHistory = []; @override void initState() { super.initState(); // 回填"记住密码"的账号密码(异步读取,不在 initState 里 await/用 context) _loadRemembered(); } /// 启动时回填记住的账号密码(密码存在系统安全存储,勾选状态一并恢复) Future _loadRemembered() async { final account = await UserStorage.instance.readRememberedAccount(); final pwd = await UserStorage.instance.readRememberedPassword(); if (!mounted) return; // 页面已销毁就停止 setState(防泄漏) setState(() { if (account != null) _accountController.text = account; if (pwd != null) { _passwordController.text = pwd; _rememberPassword = true; } }); } @override void dispose() { // 内存泄漏红线:控制器必须释放 _accountController.dispose(); _passwordController.dispose(); super.dispose(); } // ==================== 空方法占位(后续再实现) ==================== /// 《用户协议》跳转(Vue: uni.navigateTo /pages/login/userAgreement) void _goToAgreement() { context.push('/user-agreement'); } /// 《隐私政策》跳转(Vue: uni.navigateTo /pages/login/privacyPolicy) void _goToPrivacy() { context.push('/privacy-policy'); } /// 忘记密码(Vue: 弹窗提示"请联系学习官找回密码") void _forgetPassword() { _toast('请联系学习官找回密码'); } // ==================== 登录逻辑 ==================== /// 点击「立即登录」→ 校验 → 调接口 Future _handleLogin() async { if (_logging) return; // 防连点 final account = _accountController.text.trim(); final password = _passwordController.text; // ① 基础校验(对齐 Vue logInNow 的顺序) if (account.isEmpty) { _toast('请填写账号'); return; } if (password.isEmpty) { _toast('请填写密码'); return; } if (!_hasAgreed) { _toast('请先阅读并同意《用户协议》和《隐私政策》'); return; } // ② 调接口(loading 态) setState(() => _logging = true); try { final res = await UserApi.login(account: account, password: password); // ③ 成功:Token 进安全存储 + 内存(拦截器自动带 Authorization) // 防御:token 为空 = 登录响应字段解析失败(比如后端把 token 包在 // data 里,LoginResponse.fromJson 读的是顶层 json['token'])。 // 此时不落盘不赋值,直接提示,避免发出不带 token 的请求。 if (res.token.isEmpty) { if (mounted) { _toast('登录响应缺少 token 字段,请检查后端返回结构'); } return; } await UserStorage.instance.saveToken(res.token); DioClient.instance.accessToken = res.token; // ④ 登录后拉取完整用户信息并缓存(对齐 Vue: app.fetchCurrentUserExtension)。 // // 为什么登录接口成功后,还要再调一次"获取当前用户信息"接口: // - /api/v1/User/Login 主要负责鉴权,核心产物是 token; // - 真正给"我的"页、任务页头部使用的用户资料(头像、班级、积分等), // 应该以 /api/v1/User/GetCurrentUserExtension 为准; // - 这样后续页面只读 currentUserProvider,不需要每个页面自己再请求一次。 // // forceRefresh: true 的作用: // - 登录刚成功时,必须拿一份服务器最新用户资料; // - 不能继续沿用上次缓存,否则可能出现头像、积分、班级等信息还是旧的。 // // getCurrentUser(...) 内部职责: // - 请求最新用户资料; // - 成功后写入本地缓存(SharedPreferences); // - 如果网络失败,会优先回退到旧缓存,尽量不打断登录后的进入流程。 try { final user = await UserApi.getCurrentUser(forceRefresh: true); // ⑤ Riverpod 全局状态:把"完整用户信息"放进 currentUserProvider。 // // 这一步完成后: // - 全 App 里所有 ref.watch(currentUserProvider) 的页面, // 都能直接拿到当前用户; // - "我的"页不需要再请求一次用户信息接口; // - 后续如果头像局部更新,也是在这份全局状态上做 copyWith 即可。 // // 这里的 provider 负责"运行中的内存状态"; // UserStorage.saveUserInfo(...) 负责"重启后还能恢复的本地缓存"; // 两者配合起来,才是完整的用户信息链路。 ref.read(currentUserProvider.notifier).set(user); } catch (_) { debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存'); } // ⑤ 记住密码持久化(勾选才存:账号走 prefs,密码走安全存储) if (_rememberPassword) { await UserStorage.instance.saveRememberedAccount(account); await UserStorage.instance.saveRememberedPassword(password); } else { await UserStorage.instance.clearRememberedAccount(); await UserStorage.instance.clearRememberedPassword(); } // 记录登录历史(内存) _loginHistory ..remove(account) ..insert(0, account); if (mounted) { // 登录成功 → context.go('/') 替换为 4 tab 主框架(默认第 1 个"任务"页)。 // go 是"替换不留历史":按返回键不会退回登录页。 // 响应式守卫双保险:上面 set(user) 时 authTick++ 会触发守卫重算, // 已登录访问 /login → 自动踢回 '/'。这里 go('/') 显式跳转, // 两者目标一致、不冲突;就算哪天去掉守卫,登录后也能正常进主页。 // 先取根 messenger,跳转后 SnackBar 挂在新页面 Scaffold 上正常显示。 final messenger = ScaffoldMessenger.of(context); context.go('/'); messenger.showSnackBar( SnackBar( content: Text('登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}'), duration: const Duration(seconds: 2), ), ); } } on ApiException catch (e) { // ④ 失败:只 catch 业务异常,直接展示中文提示 if (mounted) _toast(e.message); } catch (e) { // 兜底:解析失败等意外 if (mounted) _toast('登录失败,请稍后重试'); } finally { if (mounted) setState(() => _logging = false); } } /// 统一 Toast(对应 Vue uni.showToast,本版用 SnackBar) void _toast(String msg) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2))); } // ==================== UI ==================== @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: SafeArea( child: SingleChildScrollView( padding: EdgeInsets.symmetric(horizontal: 25.w), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ---------- 顶部欢迎区 ---------- SizedBox(height: 30.h), _buildWelcome(), SizedBox(height: 40.h), // ---------- 账号输入框 ---------- _buildAccountField(), SizedBox(height: 15.h), // ---------- 密码输入框 ---------- _buildPasswordField(), SizedBox(height: 15.h), // ---------- 记住密码 / 忘记密码 ---------- _buildTipsRow(), SizedBox(height: 30.h), // ---------- 立即登录按钮 ---------- _buildLoginButton(), SizedBox(height: 25.h), // ---------- 协议区 ---------- _buildAgreementRow(), SizedBox(height: 20.h), ], ), ), ), ); } /// 欢迎区:品牌名 + 欢迎语(Vue 里是欢迎图) Widget _buildWelcome() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.school_rounded, size: 32.w, color: const Color(0xFF3B82F6)), SizedBox(height: 12.h), Text( '爱学蝶变', style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w700, color: const Color(0xFF323233), ), ), SizedBox(height: 6.h), Text( '11欢迎回来,请登录后继续学习', style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)), ), ], ); } /// 账号输入框(灰底圆角 + 左侧图标 + 右侧历史箭头) Widget _buildAccountField() { return Container( height: 48.h, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: const Color(0xFFF9F9F9), borderRadius: BorderRadius.circular(24.r), ), child: Row( children: [ const Icon(Icons.person_outline_rounded, color: Color(0xFF999999)), SizedBox(width: 10.w), Expanded( child: TextField( controller: _accountController, style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入账号', hintStyle: TextStyle(fontSize: 13.sp, color: const Color(0xFFC0C4CC)), ), ), ), // 历史下拉箭头(有历史才显示,点击下拉选择) if (_loginHistory.isNotEmpty) GestureDetector( onTap: () => _showHistorySheet(), child: const Icon(Icons.keyboard_arrow_down, color: Color(0xFFC0C4CC)), ), ], ), ); } /// 密码输入框(灰底圆角 + 左侧图标 + 右侧眼睛) Widget _buildPasswordField() { return Container( height: 48.h, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: const Color(0xFFF9F9F9), borderRadius: BorderRadius.circular(24.r), ), child: Row( children: [ const Icon(Icons.lock_outline_rounded, color: Color(0xFF999999)), SizedBox(width: 10.w), Expanded( child: TextField( controller: _passwordController, obscureText: _obscurePassword, style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入密码', hintStyle: TextStyle(fontSize: 13.sp, color: const Color(0xFFC0C4CC)), ), ), ), // 密码显隐切换(setState 重建才生效) GestureDetector( onTap: () => setState(() => _obscurePassword = !_obscurePassword), child: Icon( _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: const Color(0xFFC0C4CC), ), ), ], ), ); } /// 记住密码(勾选) + 忘记密码 /// 注意(测试抓到的真实 bug):375 宽下两组文字合计 277px > 可用 275px, /// 会 Row overflow 3px。修复:左边 Expanded 占满 + 间距收窄到 8.w + /// Flexible 兜底(更窄屏 ellipsis 截断,绝不溢出)。 Widget _buildTipsRow() { return Row( children: [ Expanded( child: InkWell( onTap: () => setState(() => _rememberPassword = !_rememberPassword), child: Row( children: [ Icon( _rememberPassword ? Icons.check_circle_rounded : Icons.radio_button_unchecked, size: 15.w, color: _rememberPassword ? const Color(0xFF3B82F6) : const Color(0xFFC0C4CC), ), SizedBox(width: 4.w), Flexible( child: Text( '记住密码', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13.sp, color: const Color(0xFF323233)), ), ), ], ), ), ), InkWell( onTap: _forgetPassword, child: Text( '忘记密码?', style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)), ), ), ], ); } /// 立即登录按钮(loading 时禁用) Widget _buildLoginButton() { return SizedBox( width: double.infinity, height: 48.h, child: FilledButton( onPressed: _logging ? null : _handleLogin, style: FilledButton.styleFrom( backgroundColor: const Color(0xFF3B82F6), disabledBackgroundColor: const Color(0xFF3B82F6).withValues(alpha: 0.6), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24.r)), ), child: _logging ? SizedBox( width: 18.w, height: 18.w, child: const CircularProgressIndicator( strokeWidth: 3, color: Colors.white, ), ) : Text( '立即登录', style: TextStyle(fontSize: 16.sp, color: Colors.white), ), ), ); } /// 协议区:勾选同意 + 《用户协议》《隐私政策》 Widget _buildAgreementRow() { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ InkWell( onTap: () => setState(() => _hasAgreed = !_hasAgreed), child: Icon( _hasAgreed ? Icons.check_circle_rounded : Icons.radio_button_unchecked, size: 15.w, color: _hasAgreed ? const Color(0xFF3B82F6) : const Color(0xFFC0C4CC), ), ), SizedBox(width: 6.w), Flexible( child: RichText( text: TextSpan( style: TextStyle(fontSize: 12.sp, color: const Color(0xFF323233)), children: [ const TextSpan(text: '我已阅读并同意'), TextSpan( text: '《用户协议》', style: const TextStyle(color: Color(0xFF3B82F6)), recognizer: TapGestureRecognizer()..onTap = _goToAgreement, ), const TextSpan(text: '和'), TextSpan( text: '《隐私政策》', style: const TextStyle(color: Color(0xFF3B82F6)), recognizer: TapGestureRecognizer()..onTap = _goToPrivacy, ), ], ), ), ), ], ); } /// 登录历史下拉(本版用底部弹层,对应 Vue 的下拉列表) void _showHistorySheet() { showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), builder: (sheetContext) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.symmetric(vertical: 12.h), child: Text( '选择历史账号', style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: const Color(0xFF323233), ), ), ), for (final account in _loginHistory) ListTile( title: Text(account, style: TextStyle(fontSize: 14.sp)), trailing: IconButton( icon: const Icon(Icons.close, size: 10, color: Color(0xFFC0C4CC)), onPressed: () { setState(() => _loginHistory.remove(account)); Navigator.of(sheetContext).pop(); }, ), onTap: () { _accountController.text = account; Navigator.of(sheetContext).pop(); }, ), ], ), ); }, ); } }