diff --git a/lib/core/api/auth/api.dart b/lib/core/api/auth/api.dart index 74aa562..3fbf6b2 100644 --- a/lib/core/api/auth/api.dart +++ b/lib/core/api/auth/api.dart @@ -97,6 +97,46 @@ class UserApi { await DioClient.instance.post('/api/v1/User/CloseAccount'); } + // ==================== 注册 ==================== + + /// 注册 → POST /api/v1/User/Register + /// + /// 对齐 Vue api/login `register`: { account, password },密码同样 MD5 加密 + /// Vue RegisterResponse = { code, message }(业务响应壳),Flutter 端 DioClient + /// 已自动处理成功/失败分支,成功不关心 body,失败直接抛 ApiException。 + static Future register({ + required String account, + required String password, + }) async { + await DioClient.instance.post( + '/api/v1/User/Register', + data: {'account': account.trim(), 'password': _md5(password.trim())}, + ); + } + + // ==================== 审核模式 ==================== + + /// 获取审核模式(决定是否展示"去注册"入口)→ GET /api/v1/Public/GetIsAuditMode + /// + /// 对齐 Vue api/login: `getAuditMode()`,返回值本身就是 boolean + /// (request 拦截器已解包),接口不可用时安全回退 false(不展示注册入口)。 + static Future getAuditMode() async { + try { + final data = await DioClient.instance.get( + '/api/v1/Public/GetIsAuditMode', + ); + if (data is bool) return data; + if (data is Map) { + // 兼容 { isAuditMode: true } 包装 + final v = data['isAuditMode']; + if (v is bool) return v; + } + return false; + } catch (_) { + return false; + } + } + /// MD5 加密(UniApp stringToMd5 的等价实现) static String _md5(String text) { return md5.convert(utf8.encode(text)).toString(); diff --git a/lib/core/components/center_toast.dart b/lib/core/components/center_toast.dart new file mode 100644 index 0000000..368ec90 --- /dev/null +++ b/lib/core/components/center_toast.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// ============================================================ +/// 【CenterToast】居中轻量提示 —— 对标 uni.showToast(icon: 'none') +/// +/// 一、适用场景: +/// 表单校验失败("请填写账号"/"请填写密码")、轻量成功提示等 +/// 不需要用户确认、1.2s 自动消失、不阻塞交互(IgnorePointer)。 +/// +/// 二、与 SnackBar / ConfirmBox 的分工: +/// CenterToast → 短暂轻量提示,自动消失,中间弹出 +/// SnackBar → 底部提示(信息可能较长),可手动关闭 +/// ConfirmBox → 需要用户决策的确认框,双按钮 +/// +/// 三、用法: +/// CenterToast.show(context, '请填写账号'); +/// ============================================================ +class CenterToast { + CenterToast._(); + + static OverlayEntry? _current; + + static void show( + BuildContext context, + String msg, { + Duration duration = const Duration(milliseconds: 1200), + }) { + // 连续调用时先移除上一个,避免叠加 + _current?.remove(); + + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => Positioned.fill( + child: IgnorePointer( + child: Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + msg, + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + decoration: TextDecoration.none, + ), + ), + ), + ), + ), + ), + ); + _current = entry; + Overlay.of(context).insert(entry); + Future.delayed(duration, () { + if (entry.mounted && _current == entry) { + entry.remove(); + _current = null; + } + }); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index ba2eff3..2cadd8a 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import 'package:new_learn/features/auth/legal_document_page.dart'; import 'package:new_learn/features/auth/legal_documents.dart'; import 'package:new_learn/features/auth/login_page.dart'; import 'package:new_learn/features/auth/privacy_consent_page.dart'; +import 'package:new_learn/features/auth/register_page.dart'; import 'package:new_learn/features/class/all_student_view.dart'; import 'package:new_learn/features/class/class_page.dart'; import 'package:new_learn/features/class/my_interact_page.dart'; @@ -145,8 +146,9 @@ final GoRouter appRouter = GoRouter( content: kPrivacyPolicyContent, ), ), - // ---------- 登录页 ---------- + // ---------- 登录/注册页(未登录可访问) ---------- GoRoute(path: '/login', builder: (_, _) => const LoginPage()), + GoRoute(path: '/register', builder: (_, _) => const RegisterPage()), // app_router.dart 的 routes 里加,不用管跳转逻辑 // GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()), // ---------- 以后新业务页在这里加,一行一个 ---------- @@ -154,7 +156,7 @@ final GoRouter appRouter = GoRouter( ); /// 登录守卫(对标 vue-router beforeEach): -/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页 +/// 没登录 → 强制去 /login;已登录还访问 /login 或 /register → 踢回首页 String? _guardLogin(BuildContext context, GoRouterState state) { final acceptedPrivacy = privacyAcceptedNotifier.value; final loggedIn = DioClient.instance.accessToken != null; @@ -163,11 +165,14 @@ String? _guardLogin(BuildContext context, GoRouterState state) { state.matchedLocation == '/user-agreement' || state.matchedLocation == '/privacy-policy'; final atLogin = state.matchedLocation == '/login'; + final atRegister = state.matchedLocation == '/register'; + // 未登录可白名单:隐私协议页 / 协议文档 / 登录页 / 注册页 + final atAuthFree = atLogin || atRegister || atLegalDocument; if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) { return '/privacy-consent'; } if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login'; - if (!loggedIn && !atLogin && !atLegalDocument) return '/login'; - if (loggedIn && atLogin) return '/'; + if (!loggedIn && !atAuthFree && !atPrivacyConsent) return '/login'; + if (loggedIn && (atLogin || atRegister)) return '/'; return null; } diff --git a/lib/core/storage/user_storage.dart b/lib/core/storage/user_storage.dart index e083104..26c61cc 100644 --- a/lib/core/storage/user_storage.dart +++ b/lib/core/storage/user_storage.dart @@ -4,6 +4,44 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:new_learn/core/models/user_model.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +/// ============================================================ +/// 【LoginHistoryItem】登录历史单条记录 —— 对齐 Vue loginHistory +/// +/// account → 登录账号(唯一标识,重复登录时去重用) +/// userName → 登录后拿到的昵称(展示用,可能为空) +/// password → 记住的密码(明文,存在 secure_storage 里一条 JSON) +/// 空串 "" 表示本次没勾选记住密码 +/// rememberPassword → 是否勾选了记住密码(决定密码是否回填) +/// ============================================================ +class LoginHistoryItem { + const LoginHistoryItem({ + required this.account, + required this.userName, + required this.password, + required this.rememberPassword, + }); + + final String account; + final String userName; + final String password; + final bool rememberPassword; + + Map toJson() => { + 'account': account, + 'userName': userName, + 'password': password, + 'rememberPassword': rememberPassword, + }; + + factory LoginHistoryItem.fromJson(Map json) => + LoginHistoryItem( + account: json['account'] as String? ?? '', + userName: json['userName'] as String? ?? '', + password: json['password'] as String? ?? '', + rememberPassword: json['rememberPassword'] as bool? ?? false, + ); +} + /// ============================================================ /// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存) /// @@ -118,6 +156,36 @@ class UserStorage { Future clearRememberedPassword() => _secure.delete(key: _rememberedPasswordKey); + // ---------- 登录历史(多条,最多 3 条,对齐 Vue loginHistory) ---------- + + static const String _loginHistoryKey = 'login_history'; + static const int _maxHistoryCount = 3; + + /// 读取登录历史(没存过返回空列表) + Future> readLoginHistory() async { + final raw = await _secure.read(key: _loginHistoryKey); + if (raw == null) return []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => LoginHistoryItem.fromJson(e as Map)) + .toList(); + } catch (_) { + return []; // 缓存损坏时当没历史处理 + } + } + + /// 保存登录历史(整体替换存,最多保留 3 条) + Future saveLoginHistory(List items) async { + final trimmed = items.length > _maxHistoryCount + ? items.sublist(0, _maxHistoryCount) + : items; + await _secure.write( + key: _loginHistoryKey, + value: jsonEncode(trimmed.map((e) => e.toJson()).toList()), + ); + } + /// 一键退出登录:清 Token + 用户信息 /// (记住的账号密码保留,方便用户下次登录) Future clearAll() async { diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 63cc32a..ee96fe6 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -2,43 +2,39 @@ 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/index.vue 移植 +/// 【LoginPage】登录页 —— 对照 Vue 版 pages/login/index.vue /// -/// 一、布局结构(自上而下): -/// 1. 渐变头部 + 欢迎语(原 Vue 是背景图 + welcome 图) -/// 2. 账号输入框(圆角灰底 + 左侧图标 + 右侧历史下拉箭头) -/// 3. 密码输入框(左侧图标 + 右侧"眼睛"显隐切换) -/// 4. 记住密码 / 忘记密码 -/// 5. 立即登录按钮(loading 态防重复提交) -/// 6. 协议勾选 + 《用户协议》《隐私政策》入口 +/// 一、布局结构(自上而下,对齐 Vue 视觉): +/// 1. 全屏背景图(顶部 CachedNetworkImage + 底部白色圆角卡片盖上去) +/// 2. 欢迎图(品牌 welcome image,Pad/phone 尺寸不同) +/// 3. 白色圆角卡片容器: +/// - 账号输入框(右侧下拉箭头 → Overlay 弹出登录历史) +/// - 密码输入框 +/// - 记住密码 / 忘记密码 +/// - 立即登录按钮 +/// - 注册入口(仅 isAuditMode=true 时显示) +/// - 协议勾选 + 《用户协议》《隐私政策》 /// -/// 二、登录流程(对齐 Vue logInNow): -/// 校验账号/密码 → 校验协议 → 调 UserApi.login(密码 MD5 加密) -/// → 成功: -/// ① token 写进 UserStorage(安全存储)+ DioClient.accessToken(内存,拦截器自动带) -/// ② UserApi.getCurrentUserExtension 拉全量用户信息 → 缓存到 UserStorage -/// → 失败:ApiException.message 直接 Toast,永不接触 Dio 细节 +/// 二、登录历史(对齐 Vue loginHistory): +/// - 最多 3 条,存在 flutter_secure_storage 里一条 JSON +/// - 账号框右侧有历史才显示 ⌄ 箭头 +/// - 点击箭头 / 账号框聚焦 → Overlay 下拉列表(定位到输入框正下方) +/// - 选中某条 → 回填账号,rememberPassword=true 时回填密码 +/// - 登录成功 → 插头部 + 去重 + 截断 3 条 → 持久化 /// -/// 三、与 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}); @@ -48,254 +44,494 @@ class LoginPage extends ConsumerStatefulWidget { } class _LoginPageState extends ConsumerState { - // ---------- 表单控制器(dispose 里必须释放) ---------- + // ---------- 表单控制器 ---------- final TextEditingController _accountController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); - bool _obscurePassword = true; // 密码是否打码 - bool _rememberPassword = true; // 记住密码 - bool _hasAgreed = false; // 已同意协议 - bool _logging = false; // 登录请求中(按钮 loading) + // ---------- 状态 ---------- + bool _obscurePassword = true; + bool _rememberPassword = true; + bool _hasAgreed = false; + bool _logging = false; + bool _isAuditMode = false; - /// 登录历史(对齐 Vue loginHistory,本版只存内存,不落盘) - final List _loginHistory = []; + // ---------- 登录历史 ---------- + final List _loginHistory = []; + bool _showHistory = false; + OverlayEntry? _historyOverlay; + + // 用来算账号框位置 → Overlay 定位 + final GlobalKey _accountFieldKey = GlobalKey(); @override void initState() { super.initState(); - // 回填"记住密码"的账号密码(异步读取,不在 initState 里 await/用 context) - _loadRemembered(); + _loadHistoryAndFill(); + _fetchAuditMode(); } - /// 启动时回填记住的账号密码(密码存在系统安全存储,勾选状态一并恢复) - Future _loadRemembered() async { - final account = await UserStorage.instance.readRememberedAccount(); - final pwd = await UserStorage.instance.readRememberedPassword(); - if (!mounted) return; // 页面已销毁就停止 setState(防泄漏) + /// 启动时:加载历史 → 第一条 rememberPassword=true 的回填账号+密码 → 有历史自动展开下拉 + Future _loadHistoryAndFill() async { + final history = await UserStorage.instance.readLoginHistory(); + if (!mounted) return; setState(() { - if (account != null) _accountController.text = account; - if (pwd != null) { - _passwordController.text = pwd; + _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 ==================== - /// 《用户协议》跳转(Vue: uni.navigateTo /pages/login/userAgreement) - void _goToAgreement() { - context.push('/user-agreement'); + /// 账号框聚焦 / 点击箭头 → 切换下拉 + void _toggleHistory() { + if (_loginHistory.isEmpty) return; + if (_showHistory) { + _hideHistory(); + } else { + _showHistoryOverlay(); + } } - /// 《隐私政策》跳转(Vue: uni.navigateTo /pages/login/privacyPolicy) - void _goToPrivacy() { - context.push('/privacy-policy'); + 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); } - /// 忘记密码(Vue: 弹窗提示"请联系学习官找回密码") - void _forgetPassword() { - _toast('请联系学习官找回密码'); + 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; // 防连点 + if (_logging) return; final account = _accountController.text.trim(); final password = _passwordController.text; - // ① 基础校验(对齐 Vue logInNow 的顺序) if (account.isEmpty) { - _toast('请填写账号'); + CenterToast.show(context, '请填写账号'); return; } if (password.isEmpty) { - _toast('请填写密码'); + CenterToast.show(context, '请填写密码'); return; } if (!_hasAgreed) { - _toast('请先阅读并同意《用户协议》和《隐私政策》'); - return; + final agreed = await showConfirmBox( + context, + title: '用户协议及隐私条款', + content: + '感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。' + '为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读' + '《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!', + cancelText: '不同意', + confirmText: '同意并继续', + ); + if (!agreed || !mounted) return; + setState(() => _hasAgreed = true); } - // ② 调接口(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 字段,请检查后端返回结构'); - } + 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); + // 存登录历史(userName 从拉到的完整信息里取) + await _updateLoginHistory( + account: account, + userName: user.userName, + password: password, + rememberPassword: _rememberPassword, + ); } catch (_) { debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存'); + // 即使拉用户信息失败也存历史(userName 留空) + await _updateLoginHistory( + account: account, + userName: '', + password: password, + rememberPassword: _rememberPassword, + ); } - // ⑤ 记住密码持久化(勾选才存:账号走 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}'), + 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))); + ..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: 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), - ], + 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(), + ], + ), + ), + ), + ), + ], + ), + ), + ], ), ); } - /// 欢迎区:品牌名 + 欢迎语(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)), - ), - ], - ); - } - - /// 账号输入框(灰底圆角 + 左侧图标 + 右侧历史箭头) + /// 账号输入框(带 GlobalKey 给 Overlay 定位用,右侧有历史才显示下拉箭头) Widget _buildAccountField() { return Container( + key: _accountFieldKey, height: 48.h, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( @@ -304,32 +540,51 @@ class _LoginPageState extends ConsumerState { ), child: Row( children: [ - const Icon(Icons.person_outline_rounded, color: Color(0xFF999999)), + 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: 14.sp, color: const Color(0xFF323233)), + 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)), + 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)), + 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, @@ -340,38 +595,77 @@ class _LoginPageState extends ConsumerState { ), child: Row( children: [ - const Icon(Icons.lock_outline_rounded, color: Color(0xFF999999)), + 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: 14.sp, color: const Color(0xFF323233)), + 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)), + 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), - ), + 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), + ), + ), ), ], ), ); } - /// 记住密码(勾选) + 忘记密码 - /// 注意(测试抓到的真实 bug):375 宽下两组文字合计 277px > 可用 275px, - /// 会 Row overflow 3px。修复:左边 Expanded 占满 + 间距收窄到 8.w + - /// Flexible 兜底(更窄屏 ellipsis 截断,绝不溢出)。 Widget _buildTipsRow() { return Row( children: [ @@ -384,9 +678,9 @@ class _LoginPageState extends ConsumerState { _rememberPassword ? Icons.check_circle_rounded : Icons.radio_button_unchecked, - size: 15.w, + size: 16.w, color: _rememberPassword - ? const Color(0xFF3B82F6) + ? const Color(0xFF3D7DF5) : const Color(0xFFC0C4CC), ), SizedBox(width: 4.w), @@ -395,7 +689,10 @@ class _LoginPageState extends ConsumerState { '记住密码', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF323233)), + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF323233), + ), ), ), ], @@ -406,14 +703,13 @@ class _LoginPageState extends ConsumerState { onTap: _forgetPassword, child: Text( '忘记密码?', - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)), + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)), ), ), ], ); } - /// 立即登录按钮(loading 时禁用) Widget _buildLoginButton() { return SizedBox( width: double.infinity, @@ -421,9 +717,13 @@ class _LoginPageState extends ConsumerState { 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)), + backgroundColor: const Color(0xFF3D7DF5), + disabledBackgroundColor: const Color( + 0xFF3D7DF5, + ).withValues(alpha: 0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.r), + ), ), child: _logging ? SizedBox( @@ -436,13 +736,32 @@ class _LoginPageState extends ConsumerState { ) : Text( '立即登录', - style: TextStyle(fontSize: 16.sp, color: Colors.white), + 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, @@ -450,27 +769,31 @@ class _LoginPageState extends ConsumerState { 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), + _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: 12.sp, color: const Color(0xFF323233)), + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), children: [ const TextSpan(text: '我已阅读并同意'), TextSpan( text: '《用户协议》', - style: const TextStyle(color: Color(0xFF3B82F6)), + style: const TextStyle(color: Color(0xFF3D7DF5)), recognizer: TapGestureRecognizer()..onTap = _goToAgreement, ), const TextSpan(text: '和'), TextSpan( text: '《隐私政策》', - style: const TextStyle(color: Color(0xFF3B82F6)), + style: const TextStyle(color: Color(0xFF3D7DF5)), recognizer: TapGestureRecognizer()..onTap = _goToPrivacy, ), ], @@ -480,50 +803,4 @@ class _LoginPageState extends ConsumerState { ], ); } - - /// 登录历史下拉(本版用底部弹层,对应 Vue 的下拉列表) - void _showHistorySheet() { - showModalBottomSheet( - 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(); - }, - ), - ], - ), - ); - }, - ); - } } diff --git a/lib/features/auth/register_page.dart b/lib/features/auth/register_page.dart new file mode 100644 index 0000000..e8a5bea --- /dev/null +++ b/lib/features/auth/register_page.dart @@ -0,0 +1,429 @@ +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/api/auth/api.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/components/confirm_box.dart'; + +/// ============================================================ +/// 【RegisterPage】注册页 —— 对照 Vue 版 pages/login/register.vue +/// +/// 一、表单字段(3 个,带校验): +/// 账号 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/ +/// 密码 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/ +/// 确认密码 必须与密码一致 +/// +/// 二、注册流程: +/// isFormValid → 协议勾选 → 调 UserApi.register({account, password}) +/// → 成功 Toast "注册成功" → 1 秒后返回登录页 +/// +/// 三、与 Vue 的对应关系: +/// validateField / validateAll → Flutter 版字段级校验(_validateXxx) +/// isFormValid computed → Flutter 版 _isFormValid getter +/// debounce 防重复提交 → Flutter 用 _loading 标记防连点 +/// 密码 MD5 → UserApi.register 内部已处理 +/// +/// 四、UI 风格: +/// 输入框对齐登录页(灰底 #F9F9F9 + 圆角 24.r),顶部 AppBar 标题"注册账号"。 +/// ============================================================ + +/// 账号/密码校验正则:6-16 位字母或数字(对齐 Vue register.vue 的 validateField) +final _kAccountRegex = RegExp(r'^[a-zA-Z0-9]{6,16}$'); + +class RegisterPage extends ConsumerStatefulWidget { + const RegisterPage({super.key}); + + @override + ConsumerState createState() => _RegisterPageState(); +} + +class _RegisterPageState extends ConsumerState { + // ---------- 表单控制器 ---------- + final TextEditingController _accountController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + bool _hasAgreed = false; + bool _loading = false; + + // ---------- 错误信息(字段级,与 Vue errors 对齐) ---------- + String _accountError = ''; + String _passwordError = ''; + String _confirmPasswordError = ''; + + @override + void dispose() { + _accountController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + // ==================== 校验(对齐 Vue validateField) ==================== + + String _validateAccount() { + final v = _accountController.text.trim(); + if (v.isEmpty) return '请填写账号'; + if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字'; + return ''; + } + + String _validatePassword() { + final v = _passwordController.text.trim(); + if (v.isEmpty) return '请填写密码'; + if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字'; + // 密码变化时重校验确认密码 + if (_confirmPasswordController.text.isNotEmpty) { + _confirmPasswordError = _confirmPasswordController.text == v + ? '' + : '两次输入的密码不一致'; + } + return ''; + } + + String _validateConfirmPassword() { + final v = _confirmPasswordController.text; + if (v.isEmpty) return '请再次输入密码'; + if (v != _passwordController.text) return '两次输入的密码不一致'; + return ''; + } + + /// 表单是否完整有效(对齐 Vue isFormValid computed) + bool get _isFormValid { + return _kAccountRegex.hasMatch(_accountController.text.trim()) && + _kAccountRegex.hasMatch(_passwordController.text.trim()) && + _confirmPasswordController.text.isNotEmpty && + _confirmPasswordController.text == _passwordController.text; + } + + // ==================== 注册逻辑 ==================== + + Future _handleRegister() async { + if (_loading) return; + + // 全量校验 + _accountError = _validateAccount(); + _passwordError = _validatePassword(); + _confirmPasswordError = _validateConfirmPassword(); + setState(() {}); + + if (!_isFormValid) return; + + // 未勾选协议 → 弹确认框(对齐 Vue register.vue 的协议弹窗) + if (!_hasAgreed) { + final agreed = await showConfirmBox( + context, + title: '用户协议及隐私条款', + content: + '感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。' + '为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读' + '《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!', + cancelText: '不同意', + confirmText: '同意并继续', + ); + if (!agreed || !mounted) return; + setState(() => _hasAgreed = true); + } + + setState(() => _loading = true); + try { + await UserApi.register( + account: _accountController.text.trim(), + password: _passwordController.text, + ); + if (!mounted) return; + _toast('注册成功'); + // 1 秒后回到登录页(context.go 替换当前栈,最稳不依赖 canPop) + Future.delayed(const Duration(seconds: 1), () { + if (mounted) context.go('/login'); + }); + } on ApiException catch (e) { + if (mounted) _toast(e.message); + } catch (e) { + if (mounted) _toast('注册失败,请稍后重试'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _toast(String msg) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(msg), duration: const Duration(seconds: 2)), + ); + } + + // ==================== 导航 ==================== + + void _goToAgreement() => context.push('/user-agreement'); + void _goToPrivacy() => context.push('/privacy-policy'); + + // ==================== UI ==================== + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: const Text('注册账号', style: TextStyle(color: Color(0xFF323233))), + iconTheme: const IconThemeData(color: Color(0xFF323233)), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => + context.canPop() ? context.pop() : context.go('/login'), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 25.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 20.h), + + // 账号 + _buildField( + label: '账号', + controller: _accountController, + hintText: '请输入您的账号', + errorText: _accountError, + onChanged: (_) => + setState(() => _accountError = _validateAccount()), + ), + SizedBox(height: 12.h), + + // 密码 + _buildField( + label: '密码', + controller: _passwordController, + hintText: '请输入您的密码', + obscureText: _obscurePassword, + errorText: _passwordError, + onChanged: (_) => setState(() { + _passwordError = _validatePassword(); + }), + onToggleObscure: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + SizedBox(height: 12.h), + + // 确认密码 + _buildField( + label: '确认密码', + controller: _confirmPasswordController, + hintText: '请再次输入密码', + obscureText: _obscureConfirmPassword, + errorText: _confirmPasswordError, + onChanged: (_) => setState( + () => _confirmPasswordError = _validateConfirmPassword(), + ), + onToggleObscure: () => setState( + () => _obscureConfirmPassword = !_obscureConfirmPassword, + ), + ), + + SizedBox(height: 25.h), + + // 协议勾选 + _buildAgreementRow(), + + SizedBox(height: 20.h), + + // 注册按钮 + _buildRegisterButton(), + + SizedBox(height: 20.h), + + // 已有账号,去登录 + _buildFooterLink(), + ], + ), + ), + ), + ); + } + + /// 通用输入字段(带 label + 灰底圆角 + 错误提示) + Widget _buildField({ + required String label, + required TextEditingController controller, + required String hintText, + required String errorText, + bool obscureText = false, + ValueChanged? onChanged, + VoidCallback? onToggleObscure, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 输入框容器(灰底 #F9F9F9 + 圆角 24.r,对齐登录页) + Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(24.r), + ), + child: Row( + children: [ + // label 文字(Vue register-page__label 140rpx 宽) + SizedBox( + width: 70.w, // 140rpx → .w + child: Text( + label, + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF323233), + ), + ), + ), + Expanded( + child: TextField( + controller: controller, + obscureText: obscureText, + onChanged: onChanged, + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF323233), + ), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: hintText, + hintStyle: TextStyle( + fontSize: 14.sp, + color: const Color(0xFFC0C4CC), + ), + ), + ), + ), + if (onToggleObscure != null) + GestureDetector( + onTap: onToggleObscure, + child: Icon( + obscureText + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: const Color(0xFFC0C4CC), + size: 24, + ), + ), + ], + ), + ), + // 错误提示:固定高度占位,有错误时显示文字,无错误时空白 → 输入框位置不动 + SizedBox( + height: 22.h, + child: errorText.isNotEmpty + ? Padding( + padding: EdgeInsets.only(left: 16.w, top: 6.h), + child: Text( + errorText, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFEE0A24), + ), + ), + ) + : null, + ), + ], + ); + } + + 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), + Expanded( + 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, + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildRegisterButton() { + return SizedBox( + width: double.infinity, + height: 48.h, + child: FilledButton( + onPressed: _loading ? null : _handleRegister, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF3D7DF5), + disabledBackgroundColor: _isFormValid + ? const Color(0xFF3D7DF5).withValues(alpha: 0.6) + : const Color(0xFFC0C4CC), // 表单无效时整体灰色,对齐 Vue btn--disabled + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.r), + ), + ), + child: _loading + ? 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 _buildFooterLink() { + return Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () => context.canPop() ? context.pop() : context.go('/login'), + child: Text( + '已有账号,去登录', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)), + ), + ), + ); + } +}