合并代码
This commit is contained in:
@@ -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<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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user