import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../../core/exceptions/app_exception.dart'; import '../../../core/validators/string_validator.dart'; import '../../../data/models/login_config_model.dart'; import '../../providers/auth_provider.dart'; import '../../../services/api_service.dart'; import '../../../services/auth_service.dart'; import '../../../services/captcha_service.dart'; import '../../../services/server_service.dart'; import '../../../router/app_router.dart'; import 'forgot_password_page.dart'; import 'register_page.dart'; import '../../widgets/toast_helper.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _focusNode = FocusNode(); bool _obscurePassword = true; bool _rememberMe = false; bool _isLoading = false; LoginConfigModel _loginConfig = const LoginConfigModel(); @override void initState() { super.initState(); _loadRememberedInfo(); WidgetsBinding.instance.addPostFrameCallback((_) { _loadLoginConfig(); }); } @override void dispose() { _emailController.dispose(); _passwordController.dispose(); _focusNode.dispose(); super.dispose(); } Future _loadRememberedInfo() async { final server = ServerService.instance.currentServer; if (server != null) { setState(() { if (server.email != null) { _emailController.text = server.email!; } if (server.password != null && server.rememberMe) { _passwordController.text = server.password!; } _rememberMe = server.rememberMe; }); } } Future _loadLoginConfig() async { final server = ServerService.instance.currentServer; if (server == null) return; try { await ApiService.instance.setBaseUrl(server.baseUrl); final config = await AuthService.instance.getLoginConfig().timeout( const Duration(seconds: 10), ); if (!mounted) return; setState(() => _loginConfig = config); if (config.loginCaptcha) { await CaptchaService.instance.loadCaptcha(server.baseUrl); if (mounted) setState(() {}); } else { CaptchaService.instance.clearCaptcha(); } } catch (_) {} } Future _login() async { if (!_formKey.currentState!.validate()) return; final captcha = CaptchaService.instance; if (_loginConfig.loginCaptcha && !captcha.isWebCaptchaVerified) { ToastHelper.failure('请先完成人机验证'); return; } final navigator = Navigator.of(context); final authProvider = Provider.of(context, listen: false); setState(() => _isLoading = true); try { final captchaParams = _loginConfig.loginCaptcha ? captcha.getCaptchaParams() : {}; final success = await authProvider .passwordLogin( email: _emailController.text.trim(), password: _passwordController.text, rememberMe: _rememberMe, captcha: captchaParams['captcha'], ticket: captchaParams['ticket'], ) .timeout( const Duration(seconds: 15), onTimeout: () => throw Exception('请求超时'), ); if (mounted) setState(() => _isLoading = false); if (success && mounted) { _focusNode.unfocus(); ToastHelper.success('登录成功'); await Future.delayed(const Duration(seconds: 1)); if (mounted) navigator.pushReplacementNamed(RouteNames.home); } else if (mounted) { if (_loginConfig.loginCaptcha) { await captcha.refreshCaptcha(); if (mounted) setState(() {}); } final errorMessage = authProvider.errorMessage; if (errorMessage != null && errorMessage.isNotEmpty) { final errorMsg = _parseErrorMessage(errorMessage); ToastHelper.failure(errorMsg); } else { ToastHelper.failure('登录失败'); } } } on TwoFactorRequiredException catch (e) { if (mounted) { setState(() => _isLoading = false); _showTwoFactorDialog(e.sessionId); } } catch (e) { if (mounted) { setState(() => _isLoading = false); if (_loginConfig.loginCaptcha) { await captcha.refreshCaptcha(); if (mounted) setState(() {}); } final errorMsg = _parseErrorMessage(e.toString()); ToastHelper.failure(errorMsg); } } } Future _showTwoFactorDialog(String sessionId) async { final success = await showDialog( context: context, barrierDismissible: false, builder: (context) => _TwoFactorDialog( sessionId: sessionId, email: _emailController.text.trim(), password: _passwordController.text, rememberMe: _rememberMe, ), ); if (success != true || !mounted) return; _focusNode.unfocus(); ToastHelper.success('登录成功'); await Future.delayed(const Duration(seconds: 1)); if (mounted) Navigator.of(context).pushReplacementNamed(RouteNames.home); } String _parseErrorMessage(String error) { if (error.startsWith('Exception(') || error.startsWith('AppException(')) { final startIdx = error.indexOf('('); final endIdx = error.lastIndexOf(')'); if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { return error.substring(startIdx + 1, endIdx).trim(); } } if (error.contains(':')) { final parts = error.split(':'); if (parts.length > 1) { final msg = parts.sublist(1).join(':').trim(); if (msg.isNotEmpty) return '登录失败: $msg'; } } if (error.contains('"') && error.split('"').length >= 2) { final parts = error.split('"'); if (parts.length >= 2) { final msg = parts[1].trim(); if (msg.isNotEmpty && msg != 'login') return '登录失败: $msg'; } } return error.isEmpty ? '登录失败: 未知原因' : '登录失败: $error'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final captcha = CaptchaService.instance; return Scaffold( body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: DesktopConstrained( maxContentWidth: 480, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center(child: _buildLogo()), const SizedBox(height: 32), Center( child: Text('公云存储', style: theme.textTheme.headlineMedium), ), const SizedBox(height: 32), Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Padding( padding: const EdgeInsets.all(24), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 邮箱 TextFormField( controller: _emailController, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, validator: StringValidator.validateEmail, decoration: const InputDecoration( labelText: '邮箱', hintText: '请输入邮箱地址', prefixIcon: Icon(LucideIcons.mail), ), onFieldSubmitted: (_) => _focusNode.requestFocus(), ), const SizedBox(height: 16), // 密码 TextFormField( controller: _passwordController, focusNode: _focusNode, obscureText: _obscurePassword, validator: StringValidator.validatePassword, decoration: InputDecoration( labelText: '密码', hintText: '请输入密码', prefixIcon: const Icon(LucideIcons.lock), suffixIcon: IconButton( icon: Icon( _obscurePassword ? LucideIcons.eye : LucideIcons.eyeOff, size: 20, ), onPressed: () { setState( () => _obscurePassword = !_obscurePassword, ); }, ), ), onFieldSubmitted: (_) => _login(), ), if (_loginConfig.loginCaptcha) ...[ const SizedBox(height: 16), captcha.buildCaptchaInput(context), ], const SizedBox(height: 12), // 记住我 InkWell( onTap: () => setState(() => _rememberMe = !_rememberMe), borderRadius: BorderRadius.circular(8), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 24, height: 24, child: Checkbox( value: _rememberMe, onChanged: (v) => setState( () => _rememberMe = v ?? false, ), ), ), const SizedBox(width: 8), const Text('记住我'), ], ), ), const SizedBox(height: 20), // 链接按钮 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( onPressed: () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => ForgotPasswordPage( loginConfig: _loginConfig, ), ), ); }, child: const Text('忘记密码?'), ), TextButton( onPressed: () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => RegisterPage( loginConfig: _loginConfig, ), ), ); }, child: const Text('注册账号'), ), ], ), const SizedBox(height: 16), // 登录按钮 FilledButton( onPressed: _isLoading ? null : _login, style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: _isLoading ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Text('登录'), ), ], ), ), ), ), ], ), ), ), ), ), ); } Widget _buildLogo() { return ClipOval( child: Image.asset( 'assets/images/app_logo.png', width: 96, height: 96, fit: BoxFit.cover, ), ); } } /// 两步验证输入对话框 class _TwoFactorDialog extends StatefulWidget { final String sessionId; final String email; final String password; final bool rememberMe; const _TwoFactorDialog({ required this.sessionId, required this.email, required this.password, required this.rememberMe, }); @override State<_TwoFactorDialog> createState() => _TwoFactorDialogState(); } class _TwoFactorDialogState extends State<_TwoFactorDialog> with SingleTickerProviderStateMixin { final _controller = TextEditingController(); final _focusNode = FocusNode(); bool _isSubmitting = false; late final AnimationController _shakeController; late final Animation _shakeAnimation; @override void initState() { super.initState(); _shakeController = AnimationController( duration: const Duration(milliseconds: 400), vsync: this, ); _shakeAnimation = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1), TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1), TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1), TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1), TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1), TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1), ]).animate( CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut), ); _controller.addListener(_onTextChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } @override void dispose() { _controller.removeListener(_onTextChanged); _controller.dispose(); _focusNode.dispose(); _shakeController.dispose(); super.dispose(); } void _onTextChanged() { if (_controller.text.length == 6 && !_isSubmitting) { _submit(); } } Future _submit() async { final code = _controller.text.trim(); if (code.length != 6 || int.tryParse(code) == null) return; setState(() => _isSubmitting = true); final authProvider = Provider.of(context, listen: false); try { final success = await authProvider .twoFactorLogin( otp: code, sessionId: widget.sessionId, email: widget.email, password: widget.password, rememberMe: widget.rememberMe, ) .timeout( const Duration(seconds: 5), onTimeout: () => throw Exception('请求超时'), ); if (!mounted) return; if (success) { Navigator.of(context).pop(true); } else { _onVerifyFailed(authProvider.errorMessage ?? '验证码错误'); } } catch (e) { if (mounted) _onVerifyFailed(_parse2FAError(e.toString())); } } void _onVerifyFailed(String message) { setState(() => _isSubmitting = false); _controller.clear(); _focusNode.requestFocus(); _shakeController.forward(from: 0); ToastHelper.failure(message); } String _parse2FAError(String error) { if (error.startsWith('Exception(') || error.startsWith('AppException(')) { final startIdx = error.indexOf('('); final endIdx = error.lastIndexOf(')'); if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { return error.substring(startIdx + 1, endIdx).trim(); } } return error.isEmpty ? '验证码错误' : error; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: Row( children: [ Icon(LucideIcons.shieldCheck, color: theme.colorScheme.primary), const SizedBox(width: 8), const Text('两步验证'), ], ), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '请输入身份验证器中的6位数字验证码', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 20), AnimatedBuilder( animation: _shakeAnimation, builder: (context, child) { return Transform.translate( offset: Offset(_shakeAnimation.value, 0), child: child, ); }, child: TextField( controller: _controller, focusNode: _focusNode, keyboardType: TextInputType.number, maxLength: 6, textAlign: TextAlign.center, enabled: !_isSubmitting, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 8, ), decoration: InputDecoration( counterText: '', hintText: '------', hintStyle: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 8, color: theme.colorScheme.outline.withValues(alpha: 0.4), ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), inputFormatters: [FilteringTextInputFormatter.digitsOnly], onSubmitted: (_) => _submit(), ), ), ], ), actions: [ TextButton( onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false), child: const Text('取消'), ), FilledButton( onPressed: _isSubmitting ? null : _submit, child: _isSubmitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('验证'), ), ], ); } }