diff --git a/backend/services/referral-service/src/infrastructure/external/authorization-service.client.ts b/backend/services/referral-service/src/infrastructure/external/authorization-service.client.ts index f25bde87..1dfd54e0 100644 --- a/backend/services/referral-service/src/infrastructure/external/authorization-service.client.ts +++ b/backend/services/referral-service/src/infrastructure/external/authorization-service.client.ts @@ -4,6 +4,13 @@ import { HttpService } from '@nestjs/axios'; import { firstValueFrom, timeout, catchError } from 'rxjs'; import { of } from 'rxjs'; +// authorization-service 返回格式(经过 TransformInterceptor 包装) +export interface AuthorizationServiceResponse { + success: boolean; + data: T; + timestamp: string; +} + export interface NearestAuthorizationResult { accountSequence: number | null; } @@ -37,7 +44,7 @@ export class AuthorizationServiceClient { try { const response = await firstValueFrom( this.httpService - .get( + .get>( `${this.baseUrl}/api/v1/authorization/nearest-community`, { params: { accountSequence }, @@ -49,12 +56,13 @@ export class AuthorizationServiceClient { this.logger.warn( `Failed to find nearest community for accountSequence=${accountSequence}: ${error.message}`, ); - return of({ data: { accountSequence: null } }); + return of({ data: { success: false, data: { accountSequence: null }, timestamp: '' } }); }), ), ); - return response.data.accountSequence; + // authorization-service 返回格式: { success, data: { accountSequence }, timestamp } + return response.data.data?.accountSequence ?? null; } catch (error) { this.logger.error( `Error finding nearest community for accountSequence=${accountSequence}`, @@ -77,7 +85,7 @@ export class AuthorizationServiceClient { try { const response = await firstValueFrom( this.httpService - .get( + .get>( `${this.baseUrl}/api/v1/authorization/nearest-province`, { params: { accountSequence, provinceCode }, @@ -89,12 +97,13 @@ export class AuthorizationServiceClient { this.logger.warn( `Failed to find nearest province for accountSequence=${accountSequence}, provinceCode=${provinceCode}: ${error.message}`, ); - return of({ data: { accountSequence: null } }); + return of({ data: { success: false, data: { accountSequence: null }, timestamp: '' } }); }), ), ); - return response.data.accountSequence; + // authorization-service 返回格式: { success, data: { accountSequence }, timestamp } + return response.data.data?.accountSequence ?? null; } catch (error) { this.logger.error( `Error finding nearest province for accountSequence=${accountSequence}, provinceCode=${provinceCode}`, @@ -117,7 +126,7 @@ export class AuthorizationServiceClient { try { const response = await firstValueFrom( this.httpService - .get( + .get>( `${this.baseUrl}/api/v1/authorization/nearest-city`, { params: { accountSequence, cityCode }, @@ -129,12 +138,13 @@ export class AuthorizationServiceClient { this.logger.warn( `Failed to find nearest city for accountSequence=${accountSequence}, cityCode=${cityCode}: ${error.message}`, ); - return of({ data: { accountSequence: null } }); + return of({ data: { success: false, data: { accountSequence: null }, timestamp: '' } }); }), ), ); - return response.data.accountSequence; + // authorization-service 返回格式: { success, data: { accountSequence }, timestamp } + return response.data.data?.accountSequence ?? null; } catch (error) { this.logger.error( `Error finding nearest city for accountSequence=${accountSequence}, cityCode=${cityCode}`, diff --git a/frontend/mobile-app/lib/features/planting/presentation/pages/planting_location_page.dart b/frontend/mobile-app/lib/features/planting/presentation/pages/planting_location_page.dart index 706806be..945b86fa 100644 --- a/frontend/mobile-app/lib/features/planting/presentation/pages/planting_location_page.dart +++ b/frontend/mobile-app/lib/features/planting/presentation/pages/planting_location_page.dart @@ -38,6 +38,12 @@ class PlantingLocationPage extends ConsumerStatefulWidget { } class _PlantingLocationPageState extends ConsumerState { + /// 本地存储 key 前缀 + static const String _keyProvinceName = 'planting_province_name'; + static const String _keyProvinceCode = 'planting_province_code'; + static const String _keyCityName = 'planting_city_name'; + static const String _keyCityCode = 'planting_city_code'; + /// 选中的省份名称 String? _selectedProvinceName; @@ -53,6 +59,59 @@ class _PlantingLocationPageState extends ConsumerState { /// 是否正在提交 bool _isSubmitting = false; + /// 是否有保存的省市记录(用于判断是否跳过倒计时) + bool _hasSavedLocation = false; + + @override + void initState() { + super.initState(); + _loadSavedLocation(); + } + + /// 从本地存储加载保存的省市 + void _loadSavedLocation() { + try { + final localStorage = ref.read(localStorageProvider); + final provinceName = localStorage.getString(_keyProvinceName); + final provinceCode = localStorage.getString(_keyProvinceCode); + final cityName = localStorage.getString(_keyCityName); + final cityCode = localStorage.getString(_keyCityCode); + + if (provinceName != null && provinceCode != null && + cityName != null && cityCode != null) { + setState(() { + _selectedProvinceName = provinceName; + _selectedProvinceCode = provinceCode; + _selectedCityName = cityName; + _selectedCityCode = cityCode; + _hasSavedLocation = true; + }); + debugPrint('[PlantingLocationPage] 已加载保存的省市: $provinceName · $cityName'); + } + } catch (e) { + debugPrint('[PlantingLocationPage] 加载省市失败: $e'); + } + } + + /// 保存省市到本地存储 + Future _saveLocation() async { + if (_selectedProvinceName == null || _selectedProvinceCode == null || + _selectedCityName == null || _selectedCityCode == null) { + return; + } + + try { + final localStorage = ref.read(localStorageProvider); + await localStorage.setString(_keyProvinceName, _selectedProvinceName!); + await localStorage.setString(_keyProvinceCode, _selectedProvinceCode!); + await localStorage.setString(_keyCityName, _selectedCityName!); + await localStorage.setString(_keyCityCode, _selectedCityCode!); + debugPrint('[PlantingLocationPage] 已保存省市: $_selectedProvinceName · $_selectedCityName'); + } catch (e) { + debugPrint('[PlantingLocationPage] 保存省市失败: $e'); + } + } + /// 返回上一页 void _goBack() { context.pop(); @@ -107,11 +166,17 @@ class _PlantingLocationPageState extends ConsumerState { return; } - // 显示确认弹窗(带5秒倒计时) + // 保存省市到本地(首次选择时保存) + if (!_hasSavedLocation) { + await _saveLocation(); + } + + // 显示确认弹窗(如果有保存记录则跳过倒计时) await PlantingConfirmDialog.show( context: context, province: _selectedProvinceName!, city: _selectedCityName!, + skipCountdown: _hasSavedLocation, onConfirm: _submitPlanting, ); } diff --git a/frontend/mobile-app/lib/features/planting/presentation/widgets/planting_confirm_dialog.dart b/frontend/mobile-app/lib/features/planting/presentation/widgets/planting_confirm_dialog.dart index c9482265..1ce6966e 100644 --- a/frontend/mobile-app/lib/features/planting/presentation/widgets/planting_confirm_dialog.dart +++ b/frontend/mobile-app/lib/features/planting/presentation/widgets/planting_confirm_dialog.dart @@ -13,11 +13,15 @@ class PlantingConfirmDialog extends StatefulWidget { /// 确认回调 final VoidCallback onConfirm; + /// 是否跳过倒计时(用户之前已选择过省市) + final bool skipCountdown; + const PlantingConfirmDialog({ super.key, required this.province, required this.city, required this.onConfirm, + this.skipCountdown = false, }); /// 显示确认弹窗 @@ -26,6 +30,7 @@ class PlantingConfirmDialog extends StatefulWidget { required String province, required String city, required VoidCallback onConfirm, + bool skipCountdown = false, }) { return showDialog( context: context, @@ -35,6 +40,7 @@ class PlantingConfirmDialog extends StatefulWidget { province: province, city: city, onConfirm: onConfirm, + skipCountdown: skipCountdown, ), ); } @@ -45,7 +51,7 @@ class PlantingConfirmDialog extends StatefulWidget { class _PlantingConfirmDialogState extends State { /// 倒计时秒数 - int _countdown = 5; + late int _countdown; /// 倒计时定时器 Timer? _timer; @@ -56,7 +62,11 @@ class _PlantingConfirmDialogState extends State { @override void initState() { super.initState(); - _startCountdown(); + // 如果跳过倒计时,直接设置为0,否则从5开始 + _countdown = widget.skipCountdown ? 0 : 5; + if (!widget.skipCountdown) { + _startCountdown(); + } } @override diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart new file mode 100644 index 00000000..910a0053 --- /dev/null +++ b/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart @@ -0,0 +1,787 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 绑定邮箱页面 +/// 支持绑定/更换邮箱,需要验证码验证 +class BindEmailPage extends ConsumerStatefulWidget { + const BindEmailPage({super.key}); + + @override + ConsumerState createState() => _BindEmailPageState(); +} + +class _BindEmailPageState extends ConsumerState { + /// 邮箱控制器 + final TextEditingController _emailController = TextEditingController(); + + /// 验证码控制器 + final TextEditingController _codeController = TextEditingController(); + + /// 是否正在加载 + bool _isLoading = true; + + /// 是否已绑定邮箱 + bool _isBound = false; + + /// 当前绑定的邮箱(脱敏显示) + String? _currentEmail; + + /// 是否正在发送验证码 + bool _isSendingCode = false; + + /// 验证码发送倒计时 + int _countdown = 0; + + /// 倒计时定时器 + Timer? _countdownTimer; + + /// 是否正在提交 + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _loadEmailStatus(); + } + + @override + void dispose() { + _emailController.dispose(); + _codeController.dispose(); + _countdownTimer?.cancel(); + super.dispose(); + } + + /// 加载邮箱状态 + Future _loadEmailStatus() async { + setState(() { + _isLoading = true; + }); + + try { + // TODO: 调用API获取邮箱绑定状态 + // final accountService = ref.read(accountServiceProvider); + // final emailStatus = await accountService.getEmailStatus(); + + // 模拟数据 + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + setState(() { + _isBound = false; // 模拟未绑定 + _currentEmail = null; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + /// 返回上一页 + void _goBack() { + context.pop(); + } + + /// 验证邮箱格式 + bool _isValidEmail(String email) { + return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email); + } + + /// 发送验证码 + Future _sendVerificationCode() async { + final email = _emailController.text.trim(); + + if (email.isEmpty) { + _showErrorSnackBar('请输入邮箱地址'); + return; + } + + if (!_isValidEmail(email)) { + _showErrorSnackBar('请输入有效的邮箱地址'); + return; + } + + setState(() { + _isSendingCode = true; + }); + + try { + // TODO: 调用API发送验证码 + // final accountService = ref.read(accountServiceProvider); + // await accountService.sendEmailCode(email); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isSendingCode = false; + _countdown = 60; + }); + + // 启动倒计时 + _startCountdown(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('验证码已发送'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _isSendingCode = false; + }); + + _showErrorSnackBar('发送失败: ${e.toString()}'); + } + } + } + + /// 启动倒计时 + void _startCountdown() { + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown > 0) { + setState(() { + _countdown--; + }); + } else { + timer.cancel(); + } + }); + } + + /// 绑定邮箱 + Future _bindEmail() async { + final email = _emailController.text.trim(); + final code = _codeController.text.trim(); + + if (email.isEmpty) { + _showErrorSnackBar('请输入邮箱地址'); + return; + } + + if (!_isValidEmail(email)) { + _showErrorSnackBar('请输入有效的邮箱地址'); + return; + } + + if (code.isEmpty) { + _showErrorSnackBar('请输入验证码'); + return; + } + + if (code.length != 6 || !RegExp(r'^\d{6}$').hasMatch(code)) { + _showErrorSnackBar('验证码格式错误,请输入6位数字'); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + // TODO: 调用API绑定邮箱 + // final accountService = ref.read(accountServiceProvider); + // await accountService.bindEmail(email, code); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_isBound ? '邮箱更换成功' : '邮箱绑定成功'), + backgroundColor: const Color(0xFF4CAF50), + ), + ); + + context.pop(true); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + _showErrorSnackBar('操作失败: ${e.toString()}'); + } + } + } + + /// 解绑邮箱 + Future _unbindEmail() async { + final code = _codeController.text.trim(); + + if (code.isEmpty) { + _showErrorSnackBar('请输入验证码'); + return; + } + + // 显示确认对话框 + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + '确认解绑', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + content: const Text( + '解绑后将无法通过该邮箱找回账户,确定要解绑吗?', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text( + '取消', + style: TextStyle( + color: Color(0xFF8B5A2B), + ), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + '确认解绑', + style: TextStyle( + color: Colors.red, + ), + ), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + }); + + try { + // TODO: 调用API解绑邮箱 + // final accountService = ref.read(accountServiceProvider); + // await accountService.unbindEmail(code); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isBound = false; + _currentEmail = null; + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('邮箱已解绑'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + + // 清空输入 + _emailController.clear(); + _codeController.clear(); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + _showErrorSnackBar('解绑失败: ${e.toString()}'); + } + } + } + + /// 显示错误提示 + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFF5E6), + Color(0xFFFFE4B5), + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + // 顶部导航栏 + _buildAppBar(), + // 内容区域 + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态卡片 + _buildStatusCard(), + const SizedBox(height: 24), + + // 邮箱输入 + _buildEmailInput(), + const SizedBox(height: 16), + + // 验证码输入 + _buildCodeInput(), + const SizedBox(height: 8), + + // 提示信息 + _buildHintText(), + const SizedBox(height: 32), + + // 提交按钮 + _buildSubmitButton(), + + // 解绑按钮(已绑定时显示) + if (_isBound) ...[ + const SizedBox(height: 16), + _buildUnbindButton(), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 构建顶部导航栏 + Widget _buildAppBar() { + return Container( + height: 64, + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: Row( + children: [ + // 返回按钮 + GestureDetector( + onTap: _goBack, + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: const Icon( + Icons.arrow_back, + size: 24, + color: Color(0xFF5D4037), + ), + ), + ), + // 标题 + Expanded( + child: Text( + _isBound ? '更换邮箱' : '绑定邮箱', + style: const TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + height: 1.25, + letterSpacing: -0.27, + color: Color(0xFF5D4037), + ), + textAlign: TextAlign.center, + ), + ), + // 占位 + const SizedBox(width: 48), + ], + ), + ); + } + + /// 构建状态卡片 + Widget _buildStatusCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _isBound + ? const Color(0xFF4CAF50).withValues(alpha: 0.1) + : const Color(0xFFFF9800).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + _isBound ? Icons.email : Icons.email_outlined, + color: _isBound ? const Color(0xFF4CAF50) : const Color(0xFFFF9800), + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isBound ? '已绑定' : '未绑定', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: _isBound ? const Color(0xFF4CAF50) : const Color(0xFFFF9800), + ), + ), + const SizedBox(height: 4), + Text( + _isBound + ? '当前邮箱: $_currentEmail' + : '绑定邮箱后可用于账户安全验证', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建邮箱输入框 + Widget _buildEmailInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isBound ? '新邮箱地址' : '邮箱地址', + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + height: 54, + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x80FFFFFF), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0xFF5D4037), + ), + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: InputBorder.none, + hintText: '请输入邮箱地址', + hintStyle: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0x995D4037), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: Color(0xFF8B5A2B), + size: 20, + ), + ), + ), + ), + ], + ); + } + + /// 构建验证码输入框 + Widget _buildCodeInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '验证码', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + // 验证码输入框 + Expanded( + child: Container( + height: 54, + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x80FFFFFF), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0xFF5D4037), + ), + decoration: const InputDecoration( + counterText: '', + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: InputBorder.none, + hintText: '请输入验证码', + hintStyle: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0x995D4037), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + // 发送验证码按钮 + GestureDetector( + onTap: (_countdown > 0 || _isSendingCode) ? null : _sendVerificationCode, + child: Container( + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: (_countdown > 0 || _isSendingCode) + ? const Color(0xFFD4AF37).withValues(alpha: 0.5) + : const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: _isSendingCode + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _countdown > 0 ? '${_countdown}s' : '获取验证码', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ], + ); + } + + /// 构建提示文字 + Widget _buildHintText() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF5E6), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Icon( + Icons.info_outline, + size: 16, + color: Color(0xFF8B5A2B), + ), + SizedBox(width: 8), + Expanded( + child: Text( + '验证码将发送到您输入的邮箱,请注意查收。如未收到,请检查垃圾邮件。', + style: TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ), + ], + ), + ); + } + + /// 构建提交按钮 + Widget _buildSubmitButton() { + return GestureDetector( + onTap: _isSubmitting ? null : _bindEmail, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: _isSubmitting + ? const Color(0xFFD4AF37).withValues(alpha: 0.5) + : const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x4DD4AF37), + blurRadius: 14, + offset: Offset(0, 4), + ), + ], + ), + child: Center( + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _isBound ? '更换邮箱' : '绑定邮箱', + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + height: 1.5, + letterSpacing: 0.24, + color: Colors.white, + ), + ), + ), + ), + ); + } + + /// 构建解绑按钮 + Widget _buildUnbindButton() { + return GestureDetector( + onTap: _isSubmitting ? null : _unbindEmail, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.5), + width: 1, + ), + ), + child: const Center( + child: Text( + '解绑邮箱', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + height: 1.5, + color: Colors.red, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart new file mode 100644 index 00000000..b0ae0e27 --- /dev/null +++ b/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart @@ -0,0 +1,583 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 修改密码页面 +/// 支持设置或修改登录密码 +class ChangePasswordPage extends ConsumerStatefulWidget { + const ChangePasswordPage({super.key}); + + @override + ConsumerState createState() => _ChangePasswordPageState(); +} + +class _ChangePasswordPageState extends ConsumerState { + /// 旧密码控制器 + final TextEditingController _oldPasswordController = TextEditingController(); + + /// 新密码控制器 + final TextEditingController _newPasswordController = TextEditingController(); + + /// 确认密码控制器 + final TextEditingController _confirmPasswordController = TextEditingController(); + + /// 是否显示旧密码 + bool _showOldPassword = false; + + /// 是否显示新密码 + bool _showNewPassword = false; + + /// 是否显示确认密码 + bool _showConfirmPassword = false; + + /// 是否正在提交 + bool _isSubmitting = false; + + /// 是否已设置过密码 + bool _hasPassword = false; + + /// 是否正在加载 + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadPasswordStatus(); + } + + @override + void dispose() { + _oldPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + /// 加载密码状态 + Future _loadPasswordStatus() async { + setState(() { + _isLoading = true; + }); + + try { + // TODO: 调用API获取密码状态 + // final accountService = ref.read(accountServiceProvider); + // final hasPassword = await accountService.hasPassword(); + + // 模拟数据 + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + setState(() { + _hasPassword = false; // 模拟未设置密码 + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + /// 返回上一页 + void _goBack() { + context.pop(); + } + + /// 验证密码强度 + String? _validatePasswordStrength(String password) { + if (password.length < 8) { + return '密码长度至少8位'; + } + if (password.length > 20) { + return '密码长度不能超过20位'; + } + if (!RegExp(r'[A-Za-z]').hasMatch(password)) { + return '密码必须包含字母'; + } + if (!RegExp(r'[0-9]').hasMatch(password)) { + return '密码必须包含数字'; + } + return null; + } + + /// 提交修改密码 + Future _submitPassword() async { + final oldPassword = _oldPasswordController.text; + final newPassword = _newPasswordController.text; + final confirmPassword = _confirmPasswordController.text; + + // 验证旧密码(如果已设置过密码) + if (_hasPassword && oldPassword.isEmpty) { + _showErrorSnackBar('请输入当前密码'); + return; + } + + // 验证新密码 + if (newPassword.isEmpty) { + _showErrorSnackBar('请输入新密码'); + return; + } + + final strengthError = _validatePasswordStrength(newPassword); + if (strengthError != null) { + _showErrorSnackBar(strengthError); + return; + } + + // 验证确认密码 + if (confirmPassword.isEmpty) { + _showErrorSnackBar('请确认新密码'); + return; + } + + if (newPassword != confirmPassword) { + _showErrorSnackBar('两次输入的密码不一致'); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + // TODO: 调用API修改密码 + // final accountService = ref.read(accountServiceProvider); + // await accountService.changePassword( + // oldPassword: _hasPassword ? oldPassword : null, + // newPassword: newPassword, + // ); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_hasPassword ? '密码修改成功' : '密码设置成功'), + backgroundColor: const Color(0xFF4CAF50), + ), + ); + + context.pop(true); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + _showErrorSnackBar('操作失败: ${e.toString()}'); + } + } + } + + /// 显示错误提示 + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFF5E6), + Color(0xFFFFE4B5), + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + // 顶部导航栏 + _buildAppBar(), + // 内容区域 + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态提示 + _buildStatusCard(), + const SizedBox(height: 24), + + // 密码强度要求 + _buildPasswordRequirements(), + const SizedBox(height: 24), + + // 旧密码输入(仅在已设置密码时显示) + if (_hasPassword) ...[ + _buildPasswordInput( + controller: _oldPasswordController, + label: '当前密码', + hint: '请输入当前密码', + showPassword: _showOldPassword, + onToggleVisibility: () { + setState(() { + _showOldPassword = !_showOldPassword; + }); + }, + ), + const SizedBox(height: 16), + ], + + // 新密码输入 + _buildPasswordInput( + controller: _newPasswordController, + label: '新密码', + hint: '请输入新密码', + showPassword: _showNewPassword, + onToggleVisibility: () { + setState(() { + _showNewPassword = !_showNewPassword; + }); + }, + ), + const SizedBox(height: 16), + + // 确认密码输入 + _buildPasswordInput( + controller: _confirmPasswordController, + label: '确认密码', + hint: '请再次输入新密码', + showPassword: _showConfirmPassword, + onToggleVisibility: () { + setState(() { + _showConfirmPassword = !_showConfirmPassword; + }); + }, + ), + const SizedBox(height: 32), + + // 提交按钮 + _buildSubmitButton(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 构建顶部导航栏 + Widget _buildAppBar() { + return Container( + height: 64, + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: Row( + children: [ + // 返回按钮 + GestureDetector( + onTap: _goBack, + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: const Icon( + Icons.arrow_back, + size: 24, + color: Color(0xFF5D4037), + ), + ), + ), + // 标题 + Expanded( + child: Text( + _hasPassword ? '修改密码' : '设置密码', + style: const TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + height: 1.25, + letterSpacing: -0.27, + color: Color(0xFF5D4037), + ), + textAlign: TextAlign.center, + ), + ), + // 占位 + const SizedBox(width: 48), + ], + ), + ); + } + + /// 构建状态卡片 + Widget _buildStatusCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _hasPassword + ? const Color(0xFF4CAF50).withValues(alpha: 0.1) + : const Color(0xFFFF9800).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + _hasPassword ? Icons.lock : Icons.lock_open, + color: _hasPassword ? const Color(0xFF4CAF50) : const Color(0xFFFF9800), + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _hasPassword ? '已设置密码' : '未设置密码', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: _hasPassword ? const Color(0xFF4CAF50) : const Color(0xFFFF9800), + ), + ), + const SizedBox(height: 4), + Text( + _hasPassword ? '您可以修改当前登录密码' : '设置密码后可使用密码登录', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建密码要求提示 + Widget _buildPasswordRequirements() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFF5E6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon( + Icons.info_outline, + size: 20, + color: Color(0xFF8B5A2B), + ), + SizedBox(width: 8), + Text( + '密码要求', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ], + ), + const SizedBox(height: 12), + _buildRequirementItem('长度为 8-20 个字符'), + _buildRequirementItem('必须包含字母'), + _buildRequirementItem('必须包含数字'), + ], + ), + ); + } + + /// 构建要求项 + Widget _buildRequirementItem(String text) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + const Icon( + Icons.check_circle_outline, + size: 16, + color: Color(0xFFD4AF37), + ), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ); + } + + /// 构建密码输入框 + Widget _buildPasswordInput({ + required TextEditingController controller, + required String label, + required String hint, + required bool showPassword, + required VoidCallback onToggleVisibility, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + height: 54, + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x80FFFFFF), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: TextField( + controller: controller, + obscureText: !showPassword, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0xFF5D4037), + ), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: InputBorder.none, + hintText: hint, + hintStyle: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0x995D4037), + ), + suffixIcon: GestureDetector( + onTap: onToggleVisibility, + child: Icon( + showPassword ? Icons.visibility_off : Icons.visibility, + color: const Color(0xFF8B5A2B), + size: 20, + ), + ), + ), + ), + ), + ], + ); + } + + /// 构建提交按钮 + Widget _buildSubmitButton() { + return GestureDetector( + onTap: _isSubmitting ? null : _submitPassword, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: _isSubmitting + ? const Color(0xFFD4AF37).withValues(alpha: 0.5) + : const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x4DD4AF37), + blurRadius: 14, + offset: Offset(0, 4), + ), + ], + ), + child: Center( + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _hasPassword ? '修改密码' : '设置密码', + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + height: 1.5, + letterSpacing: 0.24, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/google_auth_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/google_auth_page.dart new file mode 100644 index 00000000..d45355b5 --- /dev/null +++ b/frontend/mobile-app/lib/features/security/presentation/pages/google_auth_page.dart @@ -0,0 +1,886 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +/// 谷歌验证器页面 +/// 用于绑定/解绑谷歌验证器,提供二维码扫描和密钥复制功能 +class GoogleAuthPage extends ConsumerStatefulWidget { + const GoogleAuthPage({super.key}); + + @override + ConsumerState createState() => _GoogleAuthPageState(); +} + +class _GoogleAuthPageState extends ConsumerState { + /// 验证码输入控制器 + final TextEditingController _codeController = TextEditingController(); + + /// 是否正在加载 + bool _isLoading = true; + + /// 是否已绑定谷歌验证器 + bool _isBound = false; + + /// 谷歌验证器密钥(用于生成二维码) + String? _secret; + + /// 谷歌验证器 URI(用于生成二维码) + String? _otpAuthUri; + + /// 是否正在提交 + bool _isSubmitting = false; + + /// 错误信息 + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadGoogleAuthStatus(); + } + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + /// 加载谷歌验证器状态 + Future _loadGoogleAuthStatus() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // TODO: 调用API获取谷歌验证器状态 + // final accountService = ref.read(accountServiceProvider); + // final status = await accountService.getGoogleAuthStatus(); + + // 模拟数据 + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + setState(() { + _isBound = false; // 模拟未绑定状态 + _secret = 'JBSWY3DPEHPK3PXP'; // 模拟密钥 + _otpAuthUri = 'otpauth://totp/RWADurian:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=RWADurian'; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = '加载失败: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + /// 返回上一页 + void _goBack() { + context.pop(); + } + + /// 复制密钥到剪贴板 + void _copySecret() { + if (_secret != null) { + Clipboard.setData(ClipboardData(text: _secret!)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('密钥已复制到剪贴板'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + } + } + + /// 绑定谷歌验证器 + Future _bindGoogleAuth() async { + final code = _codeController.text.trim(); + + if (code.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请输入验证码'), + backgroundColor: Colors.red, + ), + ); + return; + } + + if (code.length != 6 || !RegExp(r'^\d{6}$').hasMatch(code)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('验证码格式错误,请输入6位数字'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + // TODO: 调用API绑定谷歌验证器 + // final accountService = ref.read(accountServiceProvider); + // await accountService.bindGoogleAuth(code); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isBound = true; + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('谷歌验证器绑定成功'), + backgroundColor: Color(0xFF4CAF50), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('绑定失败: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// 解绑谷歌验证器 + Future _unbindGoogleAuth() async { + final code = _codeController.text.trim(); + + if (code.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请输入验证码'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // 显示确认对话框 + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + '确认解绑', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + content: const Text( + '解绑后将降低账户安全性,确定要解绑谷歌验证器吗?', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text( + '取消', + style: TextStyle( + color: Color(0xFF8B5A2B), + ), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + '确认解绑', + style: TextStyle( + color: Colors.red, + ), + ), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + }); + + try { + // TODO: 调用API解绑谷歌验证器 + // final accountService = ref.read(accountServiceProvider); + // await accountService.unbindGoogleAuth(code); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isBound = false; + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('谷歌验证器已解绑'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + + // 重新加载状态(获取新的密钥) + _loadGoogleAuthStatus(); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('解绑失败: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFF5E6), + Color(0xFFFFE4B5), + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + // 顶部导航栏 + _buildAppBar(), + // 内容区域 + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ) + : _errorMessage != null + ? _buildErrorView() + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _isBound + ? _buildBoundContent() + : _buildUnboundContent(), + ), + ), + ], + ), + ), + ), + ); + } + + /// 构建顶部导航栏 + Widget _buildAppBar() { + return Container( + height: 64, + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: Row( + children: [ + // 返回按钮 + GestureDetector( + onTap: _goBack, + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: const Icon( + Icons.arrow_back, + size: 24, + color: Color(0xFF5D4037), + ), + ), + ), + // 标题 + const Expanded( + child: Text( + '谷歌验证器', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + height: 1.25, + letterSpacing: -0.27, + color: Color(0xFF5D4037), + ), + textAlign: TextAlign.center, + ), + ), + // 占位 + const SizedBox(width: 48), + ], + ), + ); + } + + /// 构建错误视图 + Widget _buildErrorView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: Color(0xFF8B5A2B), + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: _loadGoogleAuthStatus, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '重试', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ); + } + + /// 构建未绑定状态内容 + Widget _buildUnboundContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态提示 + _buildStatusCard( + icon: Icons.security, + title: '未绑定', + subtitle: '绑定谷歌验证器可提升账户安全性', + color: const Color(0xFFFF9800), + ), + const SizedBox(height: 24), + + // 步骤说明 + _buildStepCard( + stepNumber: '1', + title: '下载谷歌验证器', + description: '在应用商店搜索 "Google Authenticator" 并下载安装', + ), + const SizedBox(height: 16), + + _buildStepCard( + stepNumber: '2', + title: '扫描二维码或输入密钥', + description: '使用谷歌验证器扫描下方二维码,或手动输入密钥', + ), + const SizedBox(height: 16), + + // 二维码区域 + _buildQrCodeSection(), + const SizedBox(height: 16), + + _buildStepCard( + stepNumber: '3', + title: '输入验证码完成绑定', + description: '输入谷歌验证器显示的6位数字验证码', + ), + const SizedBox(height: 16), + + // 验证码输入 + _buildCodeInput(), + const SizedBox(height: 24), + + // 绑定按钮 + _buildSubmitButton( + text: '绑定谷歌验证器', + onTap: _bindGoogleAuth, + ), + ], + ); + } + + /// 构建已绑定状态内容 + Widget _buildBoundContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态提示 + _buildStatusCard( + icon: Icons.verified_user, + title: '已绑定', + subtitle: '您的账户已启用谷歌验证器保护', + color: const Color(0xFF4CAF50), + ), + const SizedBox(height: 24), + + // 提示卡片 + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x33FFC107), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.info_outline, + color: Color(0xFFD4AF37), + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + '安全提示', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 4), + Text( + '谷歌验证器已启用,每次登录和重要操作时需要输入验证码。如需解绑,请输入当前验证码。', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // 验证码输入 + const Text( + '输入验证码以解绑', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 12), + _buildCodeInput(), + const SizedBox(height: 24), + + // 解绑按钮 + _buildSubmitButton( + text: '解绑谷歌验证器', + onTap: _unbindGoogleAuth, + isDanger: true, + ), + ], + ); + } + + /// 构建状态卡片 + Widget _buildStatusCard({ + required IconData icon, + required String title, + required String subtitle, + required Color color, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + icon, + color: color, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建步骤卡片 + Widget _buildStepCard({ + required String stepNumber, + required String title, + required String description, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + stepNumber, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 4), + Text( + description, + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建二维码区域 + Widget _buildQrCodeSection() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Column( + children: [ + // 二维码 + if (_otpAuthUri != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: QrImageView( + data: _otpAuthUri!, + version: QrVersions.auto, + size: 180, + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Color(0xFF5D4037), + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Color(0xFF5D4037), + ), + ), + ), + const SizedBox(height: 16), + + // 密钥显示 + const Text( + '或手动输入密钥', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: _copySecret, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFFFF5E6), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _secret ?? '', + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(width: 12), + const Icon( + Icons.copy, + size: 20, + color: Color(0xFFD4AF37), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + const Text( + '点击复制密钥', + style: TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ), + ); + } + + /// 构建验证码输入框 + Widget _buildCodeInput() { + return Container( + width: double.infinity, + height: 54, + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x80FFFFFF), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + style: const TextStyle( + fontSize: 20, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + letterSpacing: 8, + height: 1.19, + color: Color(0xFF5D4037), + ), + textAlign: TextAlign.center, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration( + counterText: '', + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: InputBorder.none, + hintText: '000000', + hintStyle: TextStyle( + fontSize: 20, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + letterSpacing: 8, + height: 1.19, + color: Color(0x335D4037), + ), + ), + ), + ); + } + + /// 构建提交按钮 + Widget _buildSubmitButton({ + required String text, + required VoidCallback onTap, + bool isDanger = false, + }) { + return GestureDetector( + onTap: _isSubmitting ? null : onTap, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: isDanger + ? (_isSubmitting ? Colors.red.withValues(alpha: 0.5) : Colors.red) + : (_isSubmitting ? const Color(0xFFD4AF37).withValues(alpha: 0.5) : const Color(0xFFD4AF37)), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: isDanger + ? Colors.red.withValues(alpha: 0.3) + : const Color(0x4DD4AF37), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: Center( + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + text, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + height: 1.5, + letterSpacing: 0.24, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index b8ca9e5a..4b0e1429 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -19,6 +19,9 @@ import '../features/share/presentation/pages/share_page.dart'; import '../features/deposit/presentation/pages/deposit_usdt_page.dart'; import '../features/planting/presentation/pages/planting_quantity_page.dart'; import '../features/planting/presentation/pages/planting_location_page.dart'; +import '../features/security/presentation/pages/google_auth_page.dart'; +import '../features/security/presentation/pages/change_password_page.dart'; +import '../features/security/presentation/pages/bind_email_page.dart'; import 'route_paths.dart'; import 'route_names.dart'; @@ -211,6 +214,27 @@ final appRouterProvider = Provider((ref) { }, ), + // Google Auth Page (谷歌验证器) + GoRoute( + path: RoutePaths.googleAuth, + name: RouteNames.googleAuth, + builder: (context, state) => const GoogleAuthPage(), + ), + + // Change Password Page (修改密码) + GoRoute( + path: RoutePaths.changePassword, + name: RouteNames.changePassword, + builder: (context, state) => const ChangePasswordPage(), + ), + + // Bind Email Page (绑定邮箱) + GoRoute( + path: RoutePaths.bindEmail, + name: RouteNames.bindEmail, + builder: (context, state) => const BindEmailPage(), + ), + // Main Shell with Bottom Navigation ShellRoute( navigatorKey: _shellNavigatorKey,