feat: 实现密码重置功能与用户搜索API,优化注册登录流程

- 新增忘记密码页面与重置密码确认流程(前端+后端)
- 修复注册验证码页登录跳转路由
- 新增用户搜索API(按邮箱查询)
- 简化infra脚本,统一为app.sh
- 补充密码重置与用户API测试覆盖
- 更新runtime文档与AGENTS配置
This commit is contained in:
qzl
2026-02-27 15:22:42 +08:00
parent 0d4811fee5
commit e4e995854d
37 changed files with 2101 additions and 222 deletions
@@ -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,
),
);
}
}