feat(mining-app): 添加找回密码和修改密码功能

- 添加 /password/reset 和 /password/change API 端点
- 在 auth_remote_datasource 中实现 resetPassword 和 changePassword 方法
- 在 user_providers 中添加状态管理方法
- 创建找回密码页面 (forgot_password_page.dart)
- 创建修改密码页面 (change_password_page.dart)
- 添加路由配置
- 在登录页面添加"忘记密码"链接
- 在个人资料页面添加"修改密码"入口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-11 07:36:56 -08:00
parent 36d7b7ebfe
commit 26dce24e75
9 changed files with 659 additions and 1 deletions

View File

@ -8,6 +8,8 @@ class ApiEndpoints {
static const String refreshToken = '/auth/refresh';
static const String logout = '/auth/logout';
static const String userProfile = '/user/profile';
static const String resetPassword = '/password/reset';
static const String changePassword = '/password/change';
// Mining Service
static String shareAccount(String accountSequence) => '/api/v1/accounts/$accountSequence';

View File

@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
import '../../presentation/pages/splash/splash_page.dart';
import '../../presentation/pages/auth/login_page.dart';
import '../../presentation/pages/auth/register_page.dart';
import '../../presentation/pages/auth/forgot_password_page.dart';
import '../../presentation/pages/auth/change_password_page.dart';
import '../../presentation/pages/home/home_page.dart';
import '../../presentation/pages/contribution/contribution_page.dart';
import '../../presentation/pages/trading/trading_page.dart';
@ -26,6 +28,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: Routes.register,
builder: (context, state) => const RegisterPage(),
),
GoRoute(
path: Routes.forgotPassword,
builder: (context, state) => const ForgotPasswordPage(),
),
GoRoute(
path: Routes.changePassword,
builder: (context, state) => const ChangePasswordPage(),
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [

View File

@ -2,6 +2,8 @@ class Routes {
static const String splash = '/';
static const String login = '/login';
static const String register = '/register';
static const String forgotPassword = '/forgot-password';
static const String changePassword = '/change-password';
static const String home = '/home';
static const String contribution = '/contribution';
static const String trading = '/trading';

View File

@ -57,6 +57,8 @@ abstract class AuthRemoteDataSource {
Future<String> refreshToken(String refreshToken);
Future<void> logout(String refreshToken);
Future<UserInfo> getProfile();
Future<void> resetPassword(String phone, String smsCode, String newPassword);
Future<void> changePassword(String oldPassword, String newPassword);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
@ -162,4 +164,28 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
throw ServerException(e.toString());
}
}
@override
Future<void> resetPassword(String phone, String smsCode, String newPassword) async {
try {
await client.post(
ApiEndpoints.resetPassword,
data: {'phone': phone, 'smsCode': smsCode, 'newPassword': newPassword},
);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<void> changePassword(String oldPassword, String newPassword) async {
try {
await client.post(
ApiEndpoints.changePassword,
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
);
} catch (e) {
throw ServerException(e.toString());
}
}
}

View File

@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/app_colors.dart';
import '../../providers/user_providers.dart';
class ChangePasswordPage extends ConsumerStatefulWidget {
const ChangePasswordPage({super.key});
@override
ConsumerState<ChangePasswordPage> createState() => _ChangePasswordPageState();
}
class _ChangePasswordPageState extends ConsumerState<ChangePasswordPage> {
final _formKey = GlobalKey<FormState>();
final _oldPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscureOldPassword = true;
bool _obscureNewPassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
_oldPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _changePassword() async {
if (!_formKey.currentState!.validate()) return;
final oldPassword = _oldPasswordController.text;
final newPassword = _newPasswordController.text;
try {
await ref.read(userNotifierProvider.notifier).changePassword(oldPassword, newPassword);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('密码修改成功')),
);
context.pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('修改失败: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final userState = ref.watch(userNotifierProvider);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
title: const Text(
'修改密码',
style: TextStyle(color: Colors.black),
),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
//
TextFormField(
controller: _oldPasswordController,
obscureText: _obscureOldPassword,
decoration: InputDecoration(
labelText: '当前密码',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureOldPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscureOldPassword = !_obscureOldPassword),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入当前密码';
}
return null;
},
),
const SizedBox(height: 16),
//
TextFormField(
controller: _newPasswordController,
obscureText: _obscureNewPassword,
decoration: InputDecoration(
labelText: '新密码',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureNewPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscureNewPassword = !_obscureNewPassword),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入新密码';
}
if (value.length < 6) {
return '密码至少6位';
}
if (value == _oldPasswordController.text) {
return '新密码不能与当前密码相同';
}
return null;
},
),
const SizedBox(height: 16),
//
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: '确认新密码',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请确认新密码';
}
if (value != _newPasswordController.text) {
return '两次密码输入不一致';
}
return null;
},
),
const SizedBox(height: 32),
//
SizedBox(
height: 52,
child: ElevatedButton(
onPressed: userState.isLoading ? null : _changePassword,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: userState.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'确认修改',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(height: 16),
//
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 20, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
'密码要求',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 8),
Text(
'• 密码长度至少6位\n• 建议使用字母、数字和符号的组合\n• 新密码不能与当前密码相同',
style: TextStyle(
color: Colors.grey[600],
height: 1.5,
),
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/routes.dart';
import '../../../core/constants/app_colors.dart';
import '../../providers/user_providers.dart';
class ForgotPasswordPage extends ConsumerStatefulWidget {
const ForgotPasswordPage({super.key});
@override
ConsumerState<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
}
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
final _formKey = GlobalKey<FormState>();
final _phoneController = TextEditingController();
final _smsCodeController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
int _countDown = 0;
@override
void dispose() {
_phoneController.dispose();
_smsCodeController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _sendSmsCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty || !RegExp(r'^1[3-9]\d{9}$').hasMatch(phone)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入正确的手机号')),
);
return;
}
try {
await ref.read(userNotifierProvider.notifier).sendSmsCode(phone, 'RESET_PASSWORD');
setState(() => _countDown = 60);
_startCountDown();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('验证码已发送')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('发送失败: $e')),
);
}
}
}
void _startCountDown() {
Future.delayed(const Duration(seconds: 1), () {
if (_countDown > 0 && mounted) {
setState(() => _countDown--);
_startCountDown();
}
});
}
Future<void> _resetPassword() async {
if (!_formKey.currentState!.validate()) return;
final phone = _phoneController.text.trim();
final smsCode = _smsCodeController.text.trim();
final newPassword = _passwordController.text;
try {
await ref.read(userNotifierProvider.notifier).resetPassword(phone, smsCode, newPassword);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('密码重置成功,请登录')),
);
context.go(Routes.login);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('重置失败: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final userState = ref.watch(userNotifierProvider);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => context.pop(),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'找回密码',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'请输入您的手机号,我们将发送验证码帮您重置密码',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
//
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: '手机号',
prefixIcon: const Icon(Icons.phone),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入手机号';
}
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
return '请输入正确的手机号';
}
return null;
},
),
const SizedBox(height: 16),
//
Row(
children: [
Expanded(
child: TextFormField(
controller: _smsCodeController,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: InputDecoration(
labelText: '验证码',
prefixIcon: const Icon(Icons.sms),
counterText: '',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入验证码';
}
if (value.length != 6) {
return '验证码为6位';
}
return null;
},
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
height: 56,
child: ElevatedButton(
onPressed: _countDown > 0 ? null : _sendSmsCode,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
_countDown > 0 ? '${_countDown}s' : '获取验证码',
style: const TextStyle(fontSize: 14),
),
),
),
],
),
const SizedBox(height: 16),
//
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '新密码',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入新密码';
}
if (value.length < 6) {
return '密码至少6位';
}
return null;
},
),
const SizedBox(height: 16),
//
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: '确认新密码',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请确认新密码';
}
if (value != _passwordController.text) {
return '两次密码输入不一致';
}
return null;
},
),
const SizedBox(height: 32),
//
SizedBox(
height: 52,
child: ElevatedButton(
onPressed: userState.isLoading ? null : _resetPassword,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: userState.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'重置密码',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(height: 24),
//
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'想起密码了?',
style: TextStyle(color: Colors.grey[600]),
),
TextButton(
onPressed: () => context.pop(),
child: const Text('返回登录'),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -338,7 +338,22 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
),
const SizedBox(height: 24),
const SizedBox(height: 16),
//
if (_isPasswordLogin)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => context.push(Routes.forgotPassword),
child: Text(
'忘记密码?',
style: TextStyle(color: Colors.grey[600]),
),
),
),
const SizedBox(height: 8),
//
Row(

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/router/routes.dart';
import '../../providers/user_providers.dart';
class ProfilePage extends ConsumerWidget {
@ -94,6 +96,11 @@ class ProfilePage extends ConsumerWidget {
label: '个人信息',
onTap: () {},
),
_MenuItem(
icon: Icons.lock_outline,
label: '修改密码',
onTap: () => context.push(Routes.changePassword),
),
_MenuItem(
icon: Icons.security,
label: '安全设置',

View File

@ -192,6 +192,28 @@ class UserNotifier extends StateNotifier<UserState> {
}
}
Future<void> resetPassword(String phone, String smsCode, String newPassword) async {
state = state.copyWith(isLoading: true, error: null);
try {
await _authDataSource.resetPassword(phone, smsCode, newPassword);
state = state.copyWith(isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
rethrow;
}
}
Future<void> changePassword(String oldPassword, String newPassword) async {
state = state.copyWith(isLoading: true, error: null);
try {
await _authDataSource.changePassword(oldPassword, newPassword);
state = state.copyWith(isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
rethrow;
}
}
// Legacy method for compatibility
void login({
required String accountSequence,