Files
App.K12.Student.Miniapp_Flu…/lib/features/auth/login_page.dart
T
崔宝新 c0c673e2d2 feat: 新增我的报告模块,集成inappwebview支持H5详情页
1.  添加flutter_inappwebview依赖并配置各平台插件注册
2.  新增我的报告列表页、详情WebView页
3.  新增报告模块API和数据模型
4.  配置路由跳转,修复我的菜单跳转逻辑
5.  添加AGP9兼容补丁修复插件构建问题
6.  注释掉登录成功的SnackBar提示
2026-09-09 11:08:47 +08:00

807 lines
27 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: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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
// ---------- 表单控制器 ----------
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<LoginHistoryItem> _loginHistory = [];
bool _showHistory = false;
OverlayEntry? _historyOverlay;
// 用来算账号框位置 → Overlay 定位
final GlobalKey _accountFieldKey = GlobalKey();
@override
void initState() {
super.initState();
_loadHistoryAndFill();
_fetchAuditMode();
}
/// 启动时:加载历史 → 第一条 rememberPassword=true 的回填账号+密码 → 有历史自动展开下拉
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _forgetPassword() async {
await showConfirmBox(
context,
title: '忘记密码',
content: '请联系学习官找回密码!',
showCancel: false,
confirmText: '确定',
);
}
void _goToRegister() => context.push('/register');
// ==================== 登录逻辑 ====================
Future<void> _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,
),
],
),
),
),
],
);
}
}