Files
App.K12.Student.Miniapp_Flu…/lib/features/auth/register_page.dart
T
2026-09-09 16:15:05 +08:00

430 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/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<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
// ---------- 表单控制器 ----------
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<void> _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<void>.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<String>? 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)),
),
),
);
}
}