flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user