/// 登录页(UI-01 §4.5 主题统一,P3 计划 Task 3 基础)。 /// /// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到 /// SessionStore)→ 成功 pushReplacement /home;失败以内联 StateBadge /// (图标 + 文案 + 错误色)展示错误码本地化文案(AUTH_RATE_LIMITED 文案 /// 自带「稍后再试」;网络/校验错误各有专属文案)。提交期间按钮进入 /// 加载态防重复提交。 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart'; import '../core/app_exception.dart'; import '../core/error_l10n.dart'; import '../features/uploads/engine_scope.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.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(); bool _submitting = false; /// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。 String? _errorText; @override void dispose() { _accountController.dispose(); _passwordController.dispose(); super.dispose(); } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() { _submitting = true; _errorText = null; }); final l10n = AppLocalizations.of(context); final navigator = Navigator.of(context); try { await ref.read(apiClientProvider).login( _accountController.text.trim(), _passwordController.text, ); // 登录成功:刷新会话账号 → 引擎以新账号 scope start(Task 9 接线)。 await ref.read(sessionAccountProvider.notifier).refresh(); navigator.pushReplacementNamed('/home'); } on AppException catch (error) { // 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的 // AUTH_INVALID_CREDENTIALS 即「账号或密码错误」。 if (mounted) setState(() => _errorText = l10n.messageForError(error)); } on Object { if (mounted) setState(() => _errorText = l10n.errorGeneric); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Scaffold( appBar: AppBar(title: Text(l10n.appName)), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Padding( padding: const EdgeInsets.all(kSpace24), child: Form( key: _formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _accountController, autofillHints: const [AutofillHints.username], decoration: InputDecoration(labelText: l10n.username), validator: (value) => (value == null || value.trim().isEmpty) ? l10n.fieldRequired : null, ), const SizedBox(height: kSpace16), TextFormField( controller: _passwordController, obscureText: true, enableSuggestions: false, autocorrect: false, autofillHints: const [AutofillHints.password], onFieldSubmitted: (_) => _submitting ? null : _submit(), decoration: InputDecoration(labelText: l10n.password), validator: (value) => (value == null || value.isEmpty) ? l10n.fieldRequired : null, ), if (_errorText != null) ...[ const SizedBox(height: kSpace16), StateBadge( variant: BadgeVariant.error, label: _errorText!, ), ], const SizedBox(height: kSpace24), FilledButton( onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(l10n.login), ), const SizedBox(height: kSpace12), Text( l10n.loginHint, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ), ), ); } }