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:cached_network_image/cached_network_image.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'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/screen_adapt_template.dart'; import 'package:new_learn/core/components/confirm_box.dart'; import 'package:new_learn/core/components/center_toast.dart'; /// ============================================================ /// 【LoginPage】登录页 —— 对照 Vue 版 pages/login/index.vue /// /// 一、布局结构(自上而下,对齐 Vue 视觉): /// 1. 全屏背景图(顶部 CachedNetworkImage + 底部白色圆角卡片盖上去) /// 2. 欢迎图(品牌 welcome image,Pad/phone 尺寸不同) /// 3. 白色圆角卡片容器: /// - 账号输入框(右侧下拉箭头 → Overlay 弹出登录历史) /// - 密码输入框 /// - 记住密码 / 忘记密码 /// - 立即登录按钮 /// - 注册入口(仅 isAuditMode=true 时显示) /// - 协议勾选 + 《用户协议》《隐私政策》 /// /// 二、登录历史(对齐 Vue loginHistory): /// - 最多 3 条,存在 flutter_secure_storage 里一条 JSON /// - 账号框右侧有历史才显示 ⌄ 箭头 /// - 点击箭头 / 账号框聚焦 → Overlay 下拉列表(定位到输入框正下方) /// - 选中某条 → 回填账号,rememberPassword=true 时回填密码 /// - 登录成功 → 插头部 + 去重 + 截断 3 条 → 持久化 /// /// 三、审核模式 / 记住密码 / 登录流程(不变)。 /// ============================================================ class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override ConsumerState createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState { // ---------- 表单控制器 ---------- final TextEditingController _accountController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); // ---------- 状态 ---------- bool _obscurePassword = true; bool _rememberPassword = true; bool _hasAgreed = false; bool _logging = false; bool _isAuditMode = false; // ---------- 登录历史 ---------- final List _loginHistory = []; bool _showHistory = false; OverlayEntry? _historyOverlay; // 用来算账号框位置 → Overlay 定位 final GlobalKey _accountFieldKey = GlobalKey(); @override void initState() { super.initState(); _loadHistoryAndFill(); _fetchAuditMode(); } /// 启动时:加载历史 → 第一条 rememberPassword=true 的回填账号+密码 → 有历史自动展开下拉 Future _loadHistoryAndFill() async { final history = await UserStorage.instance.readLoginHistory(); if (!mounted) return; setState(() { _loginHistory ..clear() ..addAll(history); if (history.isNotEmpty && history.first.rememberPassword) { _accountController.text = history.first.account; _passwordController.text = history.first.password; _rememberPassword = true; } }); // 等账号框渲染完再展开(Overlay 定位需要 RenderBox) if (history.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showHistoryOverlay(); }); } } /// 非阻塞获取审核模式 Future _fetchAuditMode() async { try { final audit = await UserApi.getAuditMode(); if (!mounted) return; setState(() => _isAuditMode = audit); } catch (_) { // 接口失败保持 false } } @override void dispose() { _hideHistory(); _accountController.dispose(); _passwordController.dispose(); super.dispose(); } // ==================== 登录历史 Overlay ==================== /// 账号框聚焦 / 点击箭头 → 切换下拉 void _toggleHistory() { if (_loginHistory.isEmpty) return; if (_showHistory) { _hideHistory(); } else { _showHistoryOverlay(); } } void _showHistoryOverlay() { if (_loginHistory.isEmpty) return; _hideHistory(); final renderBox = _accountFieldKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox == null || !mounted) return; final overlay = Overlay.of(context); final size = MediaQuery.sizeOf(context); final entry = OverlayEntry( builder: (_) { // 算账号框位置 final box = _accountFieldKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return const SizedBox.shrink(); final topLeft = box.localToGlobal(Offset.zero); final fieldWidth = box.size.width; // 列表高度:最多 3 条,每条 40.h,加边框圆角 final itemHeight = 40.h; final listHeight = itemHeight * _loginHistory.length + 2; // +2 边框 return Stack( children: [ // 全屏点击遮罩(点任何地方收起) Positioned.fill( child: GestureDetector( onTap: _hideHistory, behavior: HitTestBehavior.opaque, child: const SizedBox.expand(), ), ), // 下拉列表(定位到账号框正下方) Positioned( top: topLeft.dy + box.size.height + 4.h, left: topLeft.dx, width: fieldWidth, child: Material( color: Colors.transparent, child: Container( height: listHeight, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8.r), border: Border.all(color: const Color(0xFFEEEEEE)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), offset: Offset(0, 6.h), blurRadius: 16, ), ], ), child: ListView.separated( padding: EdgeInsets.zero, itemCount: _loginHistory.length, separatorBuilder: (_, __) => const Divider(height: 1, color: Color(0xFFF0F0F0)), itemBuilder: (context, index) { final item = _loginHistory[index]; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _selectHistory(index), child: Container( height: itemHeight, padding: EdgeInsets.symmetric(horizontal: 16.w), child: Row( children: [ Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.userName.isNotEmpty ? item.userName : item.account, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: const Color(0xFF323233), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (item.userName.isNotEmpty) Text( item.account, style: TextStyle( fontSize: 11.sp, color: const Color(0xFF999999), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), GestureDetector( onTap: () => _deleteHistory(index), behavior: HitTestBehavior.opaque, child: Padding( padding: EdgeInsets.all(8.r), child: Icon( Icons.close, size: 14, color: const Color(0xFFC0C4CC), ), ), ), ], ), ), ); }, ), ), ), ), ], ); }, ); setState(() => _showHistory = true); _historyOverlay = entry; overlay.insert(entry); } void _hideHistory() { _historyOverlay?.remove(); _historyOverlay = null; if (mounted && _showHistory) { setState(() => _showHistory = false); } } /// 选中某条历史 → 回填账号 + 可选回填密码 Future _selectHistory(int index) async { final item = _loginHistory[index]; _hideHistory(); setState(() { _accountController.text = item.account; if (item.rememberPassword) { _passwordController.text = item.password; _rememberPassword = true; } else { _passwordController.text = ''; } }); } /// 删除某条历史 → 立即持久化 Future _deleteHistory(int index) async { _loginHistory.removeAt(index); await UserStorage.instance.saveLoginHistory(_loginHistory); if (!mounted) return; // 如果删空了,直接收起列表 if (_loginHistory.isEmpty) { _hideHistory(); } else { // 重绘 Overlay(因为列表长度变了) _hideHistory(); _showHistoryOverlay(); } } /// 登录成功后把本次账号存入历史(插头部 + 去重 + 截断 3 条) Future _updateLoginHistory({ required String account, required String userName, required String password, required bool rememberPassword, }) async { // 移除同账号的旧记录 _loginHistory.removeWhere((e) => e.account == account); // 插头部 _loginHistory.insert( 0, LoginHistoryItem( account: account, userName: userName, password: rememberPassword ? password : '', rememberPassword: rememberPassword, ), ); // 持久化(saveLoginHistory 内部自动截断到 3 条) await UserStorage.instance.saveLoginHistory(_loginHistory); } // ==================== 导航 ==================== void _goToAgreement() => context.push('/user-agreement'); void _goToPrivacy() => context.push('/privacy-policy'); Future _forgetPassword() async { await showConfirmBox( context, title: '忘记密码', content: '请联系学习官找回密码!', showCancel: false, confirmText: '确定', ); } void _goToRegister() => context.push('/register'); // ==================== 登录逻辑 ==================== Future _handleLogin() async { if (_logging) return; final account = _accountController.text.trim(); final password = _passwordController.text; if (account.isEmpty) { CenterToast.show(context, '请填写账号'); return; } if (password.isEmpty) { CenterToast.show(context, '请填写密码'); return; } if (!_hasAgreed) { final agreed = await showConfirmBox( context, title: '用户协议及隐私条款', content: '感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。' '为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读' '《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!', cancelText: '不同意', confirmText: '同意并继续', ); if (!agreed || !mounted) return; setState(() => _hasAgreed = true); } setState(() => _logging = true); try { final res = await UserApi.login(account: account, password: password); if (res.token.isEmpty) { if (mounted) _toast('登录响应缺少 token 字段,请检查后端返回结构'); return; } await UserStorage.instance.saveToken(res.token); DioClient.instance.accessToken = res.token; try { final user = await UserApi.getCurrentUser(forceRefresh: true); ref.read(currentUserProvider.notifier).set(user); // 存登录历史(userName 从拉到的完整信息里取) await _updateLoginHistory( account: account, userName: user.userName, password: password, rememberPassword: _rememberPassword, ); } catch (_) { debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存'); // 即使拉用户信息失败也存历史(userName 留空) await _updateLoginHistory( account: account, userName: '', password: password, rememberPassword: _rememberPassword, ); } if (mounted) { 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) { if (mounted) _toast(e.message); } catch (e) { if (mounted) _toast('登录失败,请稍后重试'); } finally { if (mounted) setState(() => _logging = false); } } void _toast(String msg) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(msg), duration: const Duration(seconds: 2)), ); } // ==================== UI ==================== @override Widget build(BuildContext context) { final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; return Scaffold( backgroundColor: const Color(0xFFF5F8FA), body: Stack( children: [ // ① 底层:全屏背景图 Positioned( top: 0, left: 0, right: 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( isPad ? '/StaticResource/2026-06-02/welcome-bg-pad1780393110709.png' : '/K12/materials/img/2026-03-27/779bc42523a24f919cb0d70de40fda51.png', ), fit: BoxFit.fitWidth, alignment: Alignment.topCenter, placeholder: (_, __) => const SizedBox.shrink(), errorWidget: (_, __, ___) => Container(height: 400.h, color: const Color(0xFFE8F0FE)), ), ), // ② 内容层 SafeArea( child: Column( children: [ SizedBox(height: 12.h), // 欢迎图 Transform.translate( offset: Offset(-7.5.w, 0), child: CachedNetworkImage( imageUrl: ImgUrl.build( isPad ? '/StaticResource/2026-06-02/welcome-pad1780393110710.png' : '/K12/materials/img/2026-03-27/a8b186d3055747ddaece6ad04d17399e.png', ), width: isPad ? 473.w : 334.w, height: isPad ? 270.h : 196.h, placeholder: (_, __) => SizedBox(width: 334.w, height: 196.h), errorWidget: (_, __, ___) => SizedBox(width: 334.w, height: 196.h), ), ), // 白色圆角卡片(SingleChildScrollView 吸收键盘弹起时的溢出) Expanded( child: Container( width: double.infinity, padding: EdgeInsets.fromLTRB( isPad ? 66.w : 25.w, 50.h, isPad ? 66.w : 25.w, 40.h, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(15.r), topRight: Radius.circular(15.r), ), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 账号输入框(key 用来算 Overlay 位置 + 下拉箭头) _buildAccountField(), SizedBox(height: 25.h), // 密码输入框 _buildPasswordField(), SizedBox(height: 25.h), _buildTipsRow(), SizedBox(height: 50.h), _buildLoginButton(), if (_isAuditMode) ...[ SizedBox(height: 20.h), _buildRegisterLink(), ], SizedBox(height: 25.h), _buildAgreementRow(), ], ), ), ), ), ], ), ), ], ), ); } /// 账号输入框(带 GlobalKey 给 Overlay 定位用,右侧有历史才显示下拉箭头) Widget _buildAccountField() { return Container( key: _accountFieldKey, height: 48.h, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: const Color(0xFFF9F9F9), borderRadius: BorderRadius.circular(24.r), ), child: Row( children: [ CachedNetworkImage( imageUrl: ImgUrl.build( '/K12/materials/img/2026-03-27/b80f8d877d1e4cb3b077ca1cb77a6d3a.png', ), width: 24.w, height: 24.h, placeholder: (_, __) => const Icon(Icons.person_outline_rounded, size: 24), errorWidget: (_, __, ___) => const Icon(Icons.person_outline_rounded, size: 24), ), SizedBox(width: 10.w), Expanded( child: TextField( controller: _accountController, style: TextStyle(fontSize: 16.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: _toggleHistory, child: Icon( _showHistory ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, color: const Color(0xFFC0C4CC), size: 24, ), ), ], ), ); } /// 密码输入框 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: [ CachedNetworkImage( imageUrl: ImgUrl.build( '/K12/materials/img/2026-03-27/07ee4c28ee1f42f8a021a227f56d81f4.png', ), width: 24.w, height: 24.h, placeholder: (_, __) => const Icon(Icons.lock_outline_rounded, size: 24), errorWidget: (_, __, ___) => const Icon(Icons.lock_outline_rounded, size: 24), ), SizedBox(width: 10.w), Expanded( child: TextField( controller: _passwordController, obscureText: _obscurePassword, style: TextStyle(fontSize: 16.sp, color: const Color(0xFF323233)), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入密码', hintStyle: TextStyle( fontSize: 13.sp, color: const Color(0xFFC0C4CC), ), ), ), ), GestureDetector( onTap: () => setState(() => _obscurePassword = !_obscurePassword), child: _obscurePassword ? CachedNetworkImage( imageUrl: ImgUrl.build( '/K12/materials/img/2026-03-27/a2572338119745c2850e38f726db9f7f.png', ), width: 24.w, height: 24.h, placeholder: (_, __) => const Icon( Icons.visibility_off_outlined, size: 24, color: Color(0xFFC0C4CC), ), errorWidget: (_, __, ___) => const Icon( Icons.visibility_off_outlined, size: 24, color: Color(0xFFC0C4CC), ), ) : CachedNetworkImage( imageUrl: ImgUrl.build( '/K12/materials/img/2026-03-27/02221c9e4c60409087f9ec51eed7b2d7.png', ), width: 24.w, height: 24.h, placeholder: (_, __) => const Icon( Icons.visibility_outlined, size: 24, color: Color(0xFFC0C4CC), ), errorWidget: (_, __, ___) => const Icon( Icons.visibility_outlined, size: 24, color: Color(0xFFC0C4CC), ), ), ), ], ), ); } 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: 16.w, color: _rememberPassword ? const Color(0xFF3D7DF5) : const Color(0xFFC0C4CC), ), SizedBox(width: 4.w), Flexible( child: Text( '记住密码', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, color: const Color(0xFF323233), ), ), ), ], ), ), ), InkWell( onTap: _forgetPassword, child: Text( '忘记密码?', style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)), ), ), ], ); } Widget _buildLoginButton() { return SizedBox( width: double.infinity, height: 48.h, child: FilledButton( onPressed: _logging ? null : _handleLogin, style: FilledButton.styleFrom( backgroundColor: const Color(0xFF3D7DF5), disabledBackgroundColor: const Color( 0xFF3D7DF5, ).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: 18.sp, color: Colors.white), ), ), ); } Widget _buildRegisterLink() { return Center( child: GestureDetector( onTap: _goToRegister, child: Text.rich( TextSpan( style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), children: [ const TextSpan(text: '没有账号?'), TextSpan( text: '去注册', style: const TextStyle(color: Color(0xFF3D7DF5)), ), ], ), ), ), ); } 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: 16.w, color: _hasAgreed ? const Color(0xFF3D7DF5) : const Color(0xFFC0C4CC), ), ), SizedBox(width: 6.w), Flexible( child: RichText( text: TextSpan( style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), children: [ const TextSpan(text: '我已阅读并同意'), TextSpan( text: '《用户协议》', style: const TextStyle(color: Color(0xFF3D7DF5)), recognizer: TapGestureRecognizer()..onTap = _goToAgreement, ), const TextSpan(text: '和'), TextSpan( text: '《隐私政策》', style: const TextStyle(color: Color(0xFF3D7DF5)), recognizer: TapGestureRecognizer()..onTap = _goToPrivacy, ), ], ), ), ), ], ); } }