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
@@ -12,6 +12,7 @@ class RegisterState extends Equatable {
final Email email;
final Password password;
final VerificationCode verificationCode;
final String inviteCode;
final FormzSubmissionStatus status;
final String? errorMessage;
final String? pendingEmail;
@@ -23,6 +24,7 @@ class RegisterState extends Equatable {
this.email = const Email.pure(),
this.password = const Password.pure(),
this.verificationCode = const VerificationCode.pure(),
this.inviteCode = '',
this.status = FormzSubmissionStatus.initial,
this.errorMessage,
this.pendingEmail,
@@ -39,6 +41,7 @@ class RegisterState extends Equatable {
Email? email,
Password? password,
VerificationCode? verificationCode,
String? inviteCode,
FormzSubmissionStatus? status,
String? errorMessage,
String? pendingEmail,
@@ -50,6 +53,7 @@ class RegisterState extends Equatable {
email: email ?? this.email,
password: password ?? this.password,
verificationCode: verificationCode ?? this.verificationCode,
inviteCode: inviteCode ?? this.inviteCode,
status: status ?? this.status,
errorMessage: errorMessage,
pendingEmail: pendingEmail ?? this.pendingEmail,
@@ -64,6 +68,7 @@ class RegisterState extends Equatable {
email,
password,
verificationCode,
inviteCode,
status,
errorMessage,
pendingEmail,
@@ -93,6 +98,10 @@ class RegisterCubit extends Cubit<RegisterState> {
emit(state.copyWith(verificationCode: VerificationCode.dirty(value)));
}
void inviteCodeChanged(String value) {
emit(state.copyWith(inviteCode: value));
}
Future<bool> submitStep1() async {
if (!state.isStep1Valid) return false;
@@ -104,6 +113,7 @@ class RegisterCubit extends Cubit<RegisterState> {
username: state.username.value,
email: state.email.value,
password: state.password.value,
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
),
);
emit(
@@ -202,6 +212,7 @@ class RegisterCubit extends Cubit<RegisterState> {
username: state.username.value,
email: state.email.value,
password: state.password.value,
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
),
);
emit(
@@ -0,0 +1,314 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:formz/formz.dart';
import '../../../../core/form_inputs/form_inputs.dart';
import '../../data/auth_repository.dart';
class ResetPasswordState extends Equatable {
final Email email;
final VerificationCode code;
final Password newPassword;
final Password confirmPassword;
final FormzSubmissionStatus status;
final String? errorMessage;
final bool isSuccess;
final int resendCountdown;
final bool codeSent;
const ResetPasswordState({
this.email = const Email.pure(),
this.code = const VerificationCode.pure(),
this.newPassword = const Password.pure(),
this.confirmPassword = const Password.pure(),
this.status = FormzSubmissionStatus.initial,
this.errorMessage,
this.isSuccess = false,
this.resendCountdown = 0,
this.codeSent = false,
});
bool get canSubmit {
if (!codeSent) {
return email.isValid && status != FormzSubmissionStatus.inProgress;
}
return email.isValid &&
code.isValid &&
newPassword.isValid &&
confirmPassword.isValid &&
newPassword.value == confirmPassword.value &&
status != FormzSubmissionStatus.inProgress;
}
ResetPasswordState copyWith({
Email? email,
VerificationCode? code,
Password? newPassword,
Password? confirmPassword,
FormzSubmissionStatus? status,
String? errorMessage,
bool? isSuccess,
int? resendCountdown,
bool? codeSent,
}) {
return ResetPasswordState(
email: email ?? this.email,
code: code ?? this.code,
newPassword: newPassword ?? this.newPassword,
confirmPassword: confirmPassword ?? this.confirmPassword,
status: status ?? this.status,
errorMessage: errorMessage,
isSuccess: isSuccess ?? this.isSuccess,
resendCountdown: resendCountdown ?? this.resendCountdown,
codeSent: codeSent ?? this.codeSent,
);
}
@override
List<Object?> get props => [
email,
code,
newPassword,
confirmPassword,
status,
errorMessage,
isSuccess,
resendCountdown,
codeSent,
];
}
class ResetPasswordCubit extends Cubit<ResetPasswordState> {
final AuthRepository _repository;
Timer? _resendTimer;
ResetPasswordCubit(this._repository) : super(const ResetPasswordState());
@override
Future<void> close() {
_resendTimer?.cancel();
return super.close();
}
void emailChanged(String value) {
emit(state.copyWith(email: Email.dirty(value), errorMessage: null));
}
void codeChanged(String value) {
emit(
state.copyWith(code: VerificationCode.dirty(value), errorMessage: null),
);
}
void newPasswordChanged(String value) {
emit(
state.copyWith(newPassword: Password.dirty(value), errorMessage: null),
);
}
void confirmPasswordChanged(String value) {
emit(
state.copyWith(
confirmPassword: Password.dirty(value),
errorMessage: null,
),
);
}
Future<void> sendCode() async {
if (state.status == FormzSubmissionStatus.inProgress ||
state.resendCountdown > 0) {
return;
}
if (!state.email.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确',
),
);
return;
}
emit(
state.copyWith(
status: FormzSubmissionStatus.inProgress,
codeSent: true,
resendCountdown: 60,
errorMessage: null,
),
);
_startResendCountdown();
try {
await _repository.requestPasswordReset(state.email.value);
emit(
state.copyWith(
status: FormzSubmissionStatus.success,
errorMessage: 'CODE_SENT_SUCCESS',
),
);
} catch (e) {
_cancelResendCountdown();
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
codeSent: false,
resendCountdown: 0,
errorMessage: '网络错误,请稍后重试',
),
);
}
}
void _cancelResendCountdown() {
_resendTimer?.cancel();
}
void _startResendCountdown() {
_cancelResendCountdown();
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final newCountdown = state.resendCountdown - 1;
if (newCountdown <= 0) {
timer.cancel();
emit(state.copyWith(resendCountdown: 0));
} else {
emit(state.copyWith(resendCountdown: newCountdown));
}
});
}
Future<void> resendCode() async {
if (state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress) {
return;
}
if (!state.email.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确',
),
);
return;
}
emit(
state.copyWith(
status: FormzSubmissionStatus.inProgress,
codeSent: true,
resendCountdown: 60,
errorMessage: null,
),
);
_startResendCountdown();
try {
await _repository.requestPasswordReset(state.email.value);
emit(
state.copyWith(
status: FormzSubmissionStatus.success,
errorMessage: 'CODE_SENT_SUCCESS',
),
);
} catch (e) {
_cancelResendCountdown();
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
resendCountdown: 0,
errorMessage: '网络错误,请稍后重试',
),
);
}
}
Future<void> submit() async {
if (!state.codeSent) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请先获取验证码',
),
);
return;
}
if (!state.email.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请输入有效的邮箱地址',
),
);
return;
}
if (!state.code.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请输入6位验证码',
),
);
return;
}
if (!state.newPassword.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '新密码至少6位',
),
);
return;
}
if (!state.confirmPassword.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请输入确认密码',
),
);
return;
}
if (state.newPassword.value != state.confirmPassword.value) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '两次密码输入不一致',
),
);
return;
}
emit(
state.copyWith(
status: FormzSubmissionStatus.inProgress,
errorMessage: null,
),
);
try {
await _repository.confirmPasswordReset(
email: state.email.value,
token: state.code.value,
newPassword: state.newPassword.value,
);
emit(
state.copyWith(status: FormzSubmissionStatus.success, isSuccess: true),
);
} catch (e) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '密码重置失败,请检查验证码',
),
);
}
}
}