530 lines
20 KiB
Dart
530 lines
20 KiB
Dart
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();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|