Files
App.K12.Student.Miniapp_Flu…/lib/features/auth/login_page.dart
T
2026-09-04 15:22:20 +08:00

530 lines
20 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.
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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
// ---------- 表单控制器(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<String> _loginHistory = [];
@override
void initState() {
super.initState();
// 回填"记住密码"的账号密码(异步读取,不在 initState 里 await/用 context)
_loadRemembered();
}
/// 启动时回填记住的账号密码(密码存在系统安全存储,勾选状态一并恢复)
Future<void> _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<void> _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<void>(
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();
},
),
],
),
);
},
);
}
}