import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/auth_provider.dart'; import '../../app/theme.dart'; import '../../services/auth_service.dart'; import '../../utils/constants.dart'; import '../../utils/credential_storage.dart'; import '../../utils/i10n.dart'; import '../../config/app_config.dart'; import '../../widgets/cloud_card.dart'; /// 登录页。 class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override ConsumerState createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState { final _formKey = GlobalKey(); final _accountController = TextEditingController(); final _passwordController = TextEditingController(); final _captchaController = TextEditingController(); bool _obscurePassword = true; /// 是否记住密码。 bool _rememberPassword = false; /// 当前验证码图片数据。 Uint8List? _captchaBytes; /// 当前验证码 UUID,登录时必须原样回传。 String _captchaUuid = ''; /// 是否正在加载验证码。 bool _captchaLoading = false; /// 验证码加载/刷新错误提示。 String? _captchaError; @override void initState() { super.initState(); _safeLoadSavedCredentials(); if (AppConfig.enableCaptcha) { _safeRefreshCaptcha(); } } /// 加载已保存的账号密码(如果之前勾选了「记住密码」)。 /// /// 包一层 try-catch:release 模式下任何同步/异步异常都不应让登录页崩成灰屏。 void _safeLoadSavedCredentials() { try { _loadSavedCredentials(); } catch (_) { // 读取本地凭据失败时使用空表单,不阻塞登录。 } } /// 验证码加载包一层 try-catch,失败时通过 _refreshCaptcha 内部的 catch 兜底。 void _safeRefreshCaptcha() { try { _refreshCaptcha(); } catch (_) { // 极端情况下同步抛错,确保页面仍可渲染。 if (mounted) { setState(() { _captchaError = '验证码加载失败,请重试'; }); } } } /// 加载已保存的账号密码(如果之前勾选了「记住密码」)。 void _loadSavedCredentials() { final cs = CredentialStorage.instance; if (cs.rememberPassword) { _rememberPassword = true; _accountController.text = cs.savedAccount ?? ''; _passwordController.text = cs.savedPassword ?? ''; } } @override void dispose() { _accountController.dispose(); _passwordController.dispose(); _captchaController.dispose(); super.dispose(); } Future _refreshCaptcha() async { if (!mounted) return; setState(() { _captchaLoading = true; _captchaError = null; }); try { final captcha = await AuthService().fetchCaptcha(); if (!mounted) return; setState(() { _captchaUuid = captcha.uuid; _captchaBytes = Uint8List.fromList(captcha.imageBytes); _captchaLoading = false; }); } on AuthException catch (e) { if (!mounted) return; setState(() { _captchaError = e.message; _captchaLoading = false; }); } catch (e) { if (!mounted) return; setState(() { _captchaError = '验证码加载失败,点击重试'; _captchaLoading = false; }); } } Future _submit() async { if (!(_formKey.currentState?.validate() ?? false)) { return; } final success = await ref.read(authProvider.notifier).login( account: _accountController.text.trim(), password: _passwordController.text, captcha: _captchaController.text.trim(), uuid: _captchaUuid, rememberPassword: _rememberPassword, ); if (!success && mounted && AppConfig.enableCaptcha) { // 登录失败后刷新验证码,并清空输入。 _captchaController.clear(); await _refreshCaptcha(); } } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final authState = ref.watch(authProvider); // 跟随主题:原有硬编码深色是为了规避 M3 动态取色;现改为显式 ColorScheme, // 直接取 token,浅色模式即为暖白品牌主场(与 logo 橙白一致)。 final scheme = Theme.of(context).colorScheme; final inputFillColor = scheme.surfaceContainerHighest; final inputBorderColor = scheme.outline; final inputTextColor = scheme.onSurface; final inputLabelColor = scheme.onSurfaceVariant; final cardColor = scheme.surfaceContainerLow; return Scaffold( backgroundColor: scheme.surface, body: Container( decoration: BoxDecoration( // 浅色模式铺 logo 橙→金渐变(品牌渗透);深色模式保持暖黑 surface。 gradient: scheme.brightness == Brightness.light ? AppBrand.cloudGradient : null, color: scheme.brightness == Brightness.light ? null : scheme.surface, ), child: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Card( color: cardColor, elevation: 4, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Padding( padding: const EdgeInsets.all(24), child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Image.asset( 'assets/images/logo.png', width: 112, height: 112, ), const SizedBox(height: 16), CloudCard( child: Row( children: [ const Icon(Icons.cloud, size: 36), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppConstants.appName, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), Text( t.appSubtitle, style: const TextStyle(fontSize: 13), ), ], ), ), ], ), ), const SizedBox(height: 32), TextFormField( controller: _accountController, style: TextStyle(color: inputTextColor), decoration: InputDecoration( labelText: '账号', labelStyle: TextStyle(color: inputLabelColor), prefixIconColor: inputLabelColor, filled: true, fillColor: inputFillColor, border: OutlineInputBorder( borderSide: BorderSide(color: inputBorderColor), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: inputBorderColor), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: scheme.primary), ), prefixIcon: const Icon(Icons.person_outline), ), validator: (value) => (value == null || value.trim().isEmpty) ? '请输入账号' : null, ), const SizedBox(height: 16), TextFormField( controller: _passwordController, obscureText: _obscurePassword, style: TextStyle(color: inputTextColor), decoration: InputDecoration( labelText: '密码', labelStyle: TextStyle(color: inputLabelColor), prefixIconColor: inputLabelColor, suffixIconColor: inputLabelColor, filled: true, fillColor: inputFillColor, border: OutlineInputBorder( borderSide: BorderSide(color: inputBorderColor), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: inputBorderColor), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: scheme.primary), ), prefixIcon: const Icon(Icons.lock_outline), suffixIcon: IconButton( icon: Icon( _obscurePassword ? Icons.visibility_off : Icons.visibility, color: inputLabelColor, ), onPressed: () => setState( () => _obscurePassword = !_obscurePassword, ), ), ), validator: (value) => (value == null || value.isEmpty) ? '请输入密码' : null, onFieldSubmitted: (_) => _submit(), ), const SizedBox(height: 16), if (AppConfig.enableCaptcha) ...[ const SizedBox(height: 16), // 验证码区域 Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( flex: 5, child: TextFormField( controller: _captchaController, style: TextStyle(color: inputTextColor), decoration: InputDecoration( labelText: '验证码', labelStyle: TextStyle(color: inputLabelColor), prefixIconColor: inputLabelColor, filled: true, fillColor: inputFillColor, border: OutlineInputBorder( borderSide: BorderSide(color: inputBorderColor), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: inputBorderColor), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: scheme.primary), ), prefixIcon: const Icon(Icons.security_outlined), ), textInputAction: TextInputAction.done, validator: (value) => (value == null || value.trim().isEmpty) ? '请输入验证码' : null, onFieldSubmitted: (_) => _submit(), ), ), const SizedBox(width: 12), Expanded( flex: 3, child: SizedBox( height: 52, child: InkWell( onTap: _captchaLoading ? null : _refreshCaptcha, borderRadius: BorderRadius.circular(12), child: Container( decoration: BoxDecoration( color: inputFillColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: inputBorderColor, ), ), alignment: Alignment.center, child: _buildCaptchaImage(), ), ), ), ), ], ), if (_captchaError != null) ...[ const SizedBox(height: 8), Text( _captchaError!, style: TextStyle( color: scheme.error, fontSize: 12, ), ), ], ], if (authState.errorMessage != null) ...[ const SizedBox(height: 12), Text( authState.errorMessage!, style: TextStyle( color: scheme.error, ), ), ], const SizedBox(height: 12), // 记住密码 Row( children: [ Checkbox( value: _rememberPassword, onChanged: (v) => setState( () => _rememberPassword = v ?? false, ), fillColor: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) ? scheme.primary : inputBorderColor, ), checkColor: scheme.onPrimary, ), GestureDetector( onTap: () => setState( () => _rememberPassword = !_rememberPassword, ), child: Text( '记住密码', style: TextStyle(color: inputTextColor), ), ), ], ), const SizedBox(height: 12), FilledButton( onPressed: authState.isLoading ? null : _submit, style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, disabledBackgroundColor: scheme.onSurface.withOpacity(0.12), padding: const EdgeInsets.symmetric(vertical: 16), ), child: authState.isLoading ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: scheme.onPrimary, ), ) : const Text('登 录'), ), const SizedBox(height: 16), // 仅 mock 模式显示演示账号;真实模式下提示无意义 if (AppConstants.useMock) Text( '演示账号:${AppConstants.mockAccount} / ' '${AppConstants.mockPassword}', textAlign: TextAlign.center, style: TextStyle( color: scheme.onSurfaceVariant, fontSize: 12), ), const SizedBox(height: 8), Text( 'v6-fix68', textAlign: TextAlign.center, style: TextStyle( color: scheme.onSurfaceVariant.withOpacity(0.5), fontSize: 10), ), ], ), ), ), ), ), ), ), ), ), ); } Widget _buildCaptchaImage() { if (_captchaLoading) { return const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ); } final bytes = _captchaBytes; if (bytes != null && bytes.isNotEmpty) { return ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.memory( bytes, fit: BoxFit.contain, width: double.infinity, height: double.infinity, errorBuilder: (context, error, stackTrace) { return const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.broken_image_outlined, size: 20, color: Color(0xFFB0B0B0), ), SizedBox(height: 2), Text( '点击刷新', style: TextStyle( fontSize: 10, color: Color(0xFFB0B0B0), ), ), ], ); }, ), ); } return const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.refresh, size: 20, color: Color(0xFFB0B0B0), ), SizedBox(height: 2), Text( '点击刷新', style: TextStyle( fontSize: 10, color: Color(0xFFB0B0B0), ), ), ], ); } }