diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 5def5234..7faaa65a 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -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'; diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 07bfff97..fc182814 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -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((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: [ diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index 56d684b4..99859c57 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -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'; diff --git a/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart index 09d441c8..c732f4e5 100644 --- a/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart @@ -57,6 +57,8 @@ abstract class AuthRemoteDataSource { Future refreshToken(String refreshToken); Future logout(String refreshToken); Future getProfile(); + Future resetPassword(String phone, String smsCode, String newPassword); + Future changePassword(String oldPassword, String newPassword); } class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { @@ -162,4 +164,28 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future 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 changePassword(String oldPassword, String newPassword) async { + try { + await client.post( + ApiEndpoints.changePassword, + data: {'oldPassword': oldPassword, 'newPassword': newPassword}, + ); + } catch (e) { + throw ServerException(e.toString()); + } + } } diff --git a/frontend/mining-app/lib/presentation/pages/auth/change_password_page.dart b/frontend/mining-app/lib/presentation/pages/auth/change_password_page.dart new file mode 100644 index 00000000..1cef1b95 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/auth/change_password_page.dart @@ -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 createState() => _ChangePasswordPageState(); +} + +class _ChangePasswordPageState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/auth/forgot_password_page.dart b/frontend/mining-app/lib/presentation/pages/auth/forgot_password_page.dart new file mode 100644 index 00000000..58816b8b --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/auth/forgot_password_page.dart @@ -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 createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _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('返回登录'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/auth/login_page.dart b/frontend/mining-app/lib/presentation/pages/auth/login_page.dart index fc8f978b..54128d16 100644 --- a/frontend/mining-app/lib/presentation/pages/auth/login_page.dart +++ b/frontend/mining-app/lib/presentation/pages/auth/login_page.dart @@ -338,7 +338,22 @@ class _LoginPageState extends ConsumerState { ), ), - 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( diff --git a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart index 7e71390d..1cad4cdd 100644 --- a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart +++ b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart @@ -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: '安全设置', diff --git a/frontend/mining-app/lib/presentation/providers/user_providers.dart b/frontend/mining-app/lib/presentation/providers/user_providers.dart index d1611498..09976a34 100644 --- a/frontend/mining-app/lib/presentation/providers/user_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/user_providers.dart @@ -192,6 +192,28 @@ class UserNotifier extends StateNotifier { } } + Future 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 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,