feat: 实现密码重置功能与用户搜索API,优化注册登录流程
- 新增忘记密码页面与重置密码确认流程(前端+后端) - 修复注册验证码页登录跳转路由 - 新增用户搜索API(按邮箱查询) - 简化infra脚本,统一为app.sh - 补充密码重置与用户API测试覆盖 - 更新runtime文档与AGENTS配置
This commit is contained in:
@@ -162,6 +162,8 @@ class _LoginViewState extends State<LoginView> {
|
||||
? null
|
||||
: _handleLogin,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildForgotPassword(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -236,6 +238,20 @@ class _LoginViewState extends State<LoginView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForgotPassword() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/reset-password'),
|
||||
child: const Text(
|
||||
'忘记密码?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/register'),
|
||||
|
||||
@@ -36,6 +36,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
final _nicknameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _inviteCodeController = TextEditingController();
|
||||
bool _obscureText = true;
|
||||
|
||||
@override
|
||||
@@ -43,6 +44,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
_nicknameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_inviteCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
cubit.usernameChanged(_nicknameController.text);
|
||||
cubit.emailChanged(_emailController.text);
|
||||
cubit.passwordChanged(_passwordController.text);
|
||||
cubit.inviteCodeChanged(_inviteCodeController.text);
|
||||
|
||||
if (!cubit.state.isStep1Valid || cubit.state.isSending) {
|
||||
String? errorMsg;
|
||||
@@ -159,6 +162,8 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
const SizedBox(height: 12),
|
||||
_buildPasswordInput(),
|
||||
const SizedBox(height: 12),
|
||||
_buildInput('邀请码(选填)', '请输入邀请码', _inviteCodeController),
|
||||
const SizedBox(height: 12),
|
||||
_buildStepIndicator(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
|
||||
@@ -48,10 +48,22 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
||||
Timer? _countdownTimer;
|
||||
int _countdown = 0;
|
||||
bool _firstSendCompleted = false;
|
||||
bool _hintShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_hintShown) {
|
||||
_hintShown = true;
|
||||
Toast.show(
|
||||
context,
|
||||
'验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册',
|
||||
type: ToastType.info,
|
||||
duration: const Duration(seconds: 5),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -331,7 +343,7 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
||||
|
||||
Widget _buildFooter() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
onTap: () => context.go('/'),
|
||||
child: const Text(
|
||||
'已有账号?去登录',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../presentation/cubits/reset_password_cubit.dart';
|
||||
import '../../data/auth_repository.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatelessWidget {
|
||||
const ResetPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ResetPasswordCubit(sl<AuthRepository>()),
|
||||
child: const ResetPasswordView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResetPasswordView extends StatefulWidget {
|
||||
const ResetPasswordView({super.key});
|
||||
|
||||
@override
|
||||
State<ResetPasswordView> createState() => _ResetPasswordViewState();
|
||||
}
|
||||
|
||||
class _ResetPasswordViewState extends State<ResetPasswordView> {
|
||||
final _emailController = TextEditingController();
|
||||
final _codeController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_codeController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
final cubit = context.read<ResetPasswordCubit>();
|
||||
cubit.emailChanged(_emailController.text);
|
||||
cubit.codeChanged(_codeController.text);
|
||||
cubit.newPasswordChanged(_passwordController.text);
|
||||
cubit.confirmPasswordChanged(_confirmPasswordController.text);
|
||||
|
||||
await cubit.submit();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ResetPasswordCubit, ResetPasswordState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status ||
|
||||
previous.errorMessage != current.errorMessage ||
|
||||
previous.codeSent != current.codeSent,
|
||||
listener: (context, state) {
|
||||
if (state.status == FormzSubmissionStatus.success && state.isSuccess) {
|
||||
Toast.show(context, '密码重置成功,请使用新密码登录', type: ToastType.success);
|
||||
context.go('/');
|
||||
} else if (state.status == FormzSubmissionStatus.success &&
|
||||
state.codeSent &&
|
||||
state.errorMessage == 'CODE_SENT_SUCCESS') {
|
||||
Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success);
|
||||
} else if (state.status == FormzSubmissionStatus.failure &&
|
||||
state.errorMessage != null &&
|
||||
state.errorMessage != '' &&
|
||||
state.errorMessage != 'CODE_SENT_SUCCESS') {
|
||||
Toast.show(context, state.errorMessage!, type: ToastType.error);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildTitle(),
|
||||
const SizedBox(height: 32),
|
||||
_buildFormContainer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
return const Text(
|
||||
'忘记密码',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormContainer() {
|
||||
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
width: 327,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildEmailInput(state.email.displayError != null),
|
||||
const SizedBox(height: 12),
|
||||
_buildCodeInput(state.code.displayError != null, state),
|
||||
const SizedBox(height: 12),
|
||||
_buildPasswordInput(state.newPassword.displayError != null),
|
||||
const SizedBox(height: 12),
|
||||
_buildConfirmPasswordInput(
|
||||
state.confirmPassword.displayError != null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSubmitButton(state),
|
||||
const SizedBox(height: 16),
|
||||
_buildBackToLogin(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmailInput(bool hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'邮箱',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().emailChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入邮箱',
|
||||
errorText: hasError ? ' ' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeInput(bool hasError, ResetPasswordState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().codeChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入 6 位验证码',
|
||||
errorText: hasError ? ' ' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
state.resendCountdown > 0 ||
|
||||
state.status == FormzSubmissionStatus.inProgress
|
||||
? null
|
||||
: () {
|
||||
if (state.codeSent) {
|
||||
context.read<ResetPasswordCubit>().resendCode();
|
||||
} else {
|
||||
context.read<ResetPasswordCubit>().sendCode();
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: state.codeSent
|
||||
? AppColors.background
|
||||
: AppColors.primary,
|
||||
foregroundColor: state.codeSent
|
||||
? AppColors.primary
|
||||
: AppColors.primaryForeground,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
),
|
||||
child: Text(
|
||||
state.resendCountdown > 0
|
||||
? '${state.resendCountdown}秒'
|
||||
: (state.codeSent ? '重新发送' : '发送验证码'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordInput(bool hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'新密码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().newPasswordChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入新密码(至少 6 位)',
|
||||
errorText: hasError ? ' ' : null,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
size: 20,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmPasswordInput(bool hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'确认密码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().confirmPasswordChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请再次输入新密码',
|
||||
errorText: hasError ? ' ' : null,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
size: 20,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton(ResetPasswordState state) {
|
||||
final isLoading = state.status == FormzSubmissionStatus.inProgress;
|
||||
final isDisabled = isLoading || !state.codeSent;
|
||||
|
||||
return AppButton(
|
||||
text: '重置密码',
|
||||
onPressed: isDisabled ? null : _handleSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackToLogin() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.go('/'),
|
||||
child: const Text(
|
||||
'返回登录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user