From 55285b57a733bbd8e21e53e950308ddcfcb92f97 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 22:42:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E7=8F=AD=E7=BA=A7=E7=A0=81?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=89=8D=E5=90=8E=E7=AB=AF=E8=81=94=E8=B0=83?= =?UTF-8?q?=20=E2=80=94=20AuthBloc=E6=8E=A5=E5=85=A5API=20+=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=AE=A1=E6=95=B0=E9=94=81=E5=AE=9AUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/app.dart | 5 +- app/lib/features/auth/bloc/auth_bloc.dart | 73 +++++- app/lib/features/auth/bloc/auth_state.dart | 12 + .../auth/views/class_code_join_page.dart | 225 +++++++++++++----- app/lib/features/auth/views/login_page.dart | 11 +- .../auth/views/role_selection_page.dart | 5 +- 6 files changed, 261 insertions(+), 70 deletions(-) diff --git a/app/lib/app.dart b/app/lib/app.dart index 491a7a8..f5a26a1 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -46,7 +46,10 @@ class NuanjiApp extends StatelessWidget { final syncEngine = SyncEngine(apiClient: apiClient); final classRepository = ClassRepository(api: apiClient); final settingsBloc = SettingsBloc(); - final authBloc = AuthBloc(authRepository: authRepository); + final authBloc = AuthBloc( + authRepository: authRepository, + classRepository: classRepository, + ); // 启动时检查认证状态 authBloc.add(const AppStarted()); diff --git a/app/lib/features/auth/bloc/auth_bloc.dart b/app/lib/features/auth/bloc/auth_bloc.dart index 6db3ad1..389c077 100644 --- a/app/lib/features/auth/bloc/auth_bloc.dart +++ b/app/lib/features/auth/bloc/auth_bloc.dart @@ -4,6 +4,7 @@ // ↕ // Authenticating → Authenticated/AuthError +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logger/logger.dart'; @@ -11,6 +12,7 @@ import '../../../data/models/auth_token.dart'; import '../../../data/models/user.dart'; import '../../../data/remote/api_client.dart'; import '../../../data/repositories/auth_repository.dart'; +import '../../../data/repositories/class_repository.dart'; part 'auth_event.dart'; part 'auth_state.dart'; @@ -18,10 +20,14 @@ part 'auth_state.dart'; /// 认证 BLoC — 处理所有认证相关的状态转换 class AuthBloc extends Bloc { final AuthRepository _authRepository; + final ClassRepository? _classRepository; final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0)); - AuthBloc({required AuthRepository authRepository}) - : _authRepository = authRepository, + AuthBloc({ + required AuthRepository authRepository, + ClassRepository? classRepository, + }) : _authRepository = authRepository, + _classRepository = classRepository, super(const AuthInitial()) { // 注册事件处理器 on(_onAppStarted); @@ -138,13 +144,64 @@ class AuthBloc extends Bloc { final currentState = state; if (currentState is! Authenticated) return; - // TODO: 调用后端 API 验证班级码并加入班级 - // 当前先标记为已完成,班级码验证在 F8 阶段完善 - emit(currentState.copyWith( - needsClassCode: false, - )); + // 如果没有 ClassRepository(离线模式),直接跳过 + final classRepo = _classRepository; + if (classRepo == null) { + _logger.w('ClassRepository 不可用,跳过班级码验证'); + emit(currentState.copyWith(needsClassCode: false)); + return; + } - _logger.i('班级码加入: ${event.classCode}'); + emit(currentState.copyWith(isLoading: true)); + + try { + // 调用后端 API 验证班级码并加入班级 + await classRepo.joinClass( + event.classCode, + nickname: currentState.user.displayName, + ); + + // 成功 — 清除班级码需求 + emit(currentState.copyWith( + needsClassCode: false, + isLoading: false, + )); + + _logger.i('班级码加入成功: ${event.classCode}'); + } on DioException catch (e) { + final statusCode = e.response?.statusCode; + String errorMessage = '加入班级失败,请重试'; + + if (statusCode == 400) { + // 班级码无效或已过期 + final body = e.response?.data; + if (body is Map && body['message'] is String) { + errorMessage = body['message'] as String; + } else { + errorMessage = '班级码无效,请检查后重新输入'; + } + } else if (statusCode == 429) { + // 尝试次数过多 — 锁定 + errorMessage = '尝试次数过多,请等待 30 分钟后再试'; + } + + _logger.w('班级码验证失败 ($statusCode): $errorMessage'); + emit(currentState.copyWith( + isLoading: false, + classCodeError: errorMessage, + )); + } on OfflineException { + emit(currentState.copyWith( + isLoading: false, + classCodeError: '网络不可用,请检查网络后重试', + )); + } catch (e) { + _logger.e('班级码验证异常: $e'); + emit(currentState.copyWith( + isLoading: false, + classCodeError: '加入班级失败,请稍后重试', + )); + } } /// 用户登出 diff --git a/app/lib/features/auth/bloc/auth_state.dart b/app/lib/features/auth/bloc/auth_state.dart index 194912b..7b2191e 100644 --- a/app/lib/features/auth/bloc/auth_state.dart +++ b/app/lib/features/auth/bloc/auth_state.dart @@ -40,21 +40,33 @@ final class Authenticated extends AuthState { /// 是否需要班级码加入(学生/家长角色) final bool needsClassCode; + /// 是否正在加载(班级码验证中) + final bool isLoading; + + /// 班级码验证错误信息 + final String? classCodeError; + const Authenticated({ required this.user, this.needsRoleSelection = false, this.needsClassCode = false, + this.isLoading = false, + this.classCodeError, }); Authenticated copyWith({ User? user, bool? needsRoleSelection, bool? needsClassCode, + bool? isLoading, + String? classCodeError, }) => Authenticated( user: user ?? this.user, needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection, needsClassCode: needsClassCode ?? this.needsClassCode, + isLoading: isLoading ?? this.isLoading, + classCodeError: classCodeError, ); } diff --git a/app/lib/features/auth/views/class_code_join_page.dart b/app/lib/features/auth/views/class_code_join_page.dart index 1ced3e7..05e1a0a 100644 --- a/app/lib/features/auth/views/class_code_join_page.dart +++ b/app/lib/features/auth/views/class_code_join_page.dart @@ -31,6 +31,19 @@ class _ClassCodeJoinPageState extends State { (_) => FocusNode(), ); + int _failedAttempts = 0; + DateTime? _lockoutEndTime; + + /// 当前是否被锁定 + bool get _isCurrentlyLocked { + if (_lockoutEndTime == null) return false; + if (DateTime.now().isAfter(_lockoutEndTime!)) { + _lockoutEndTime = null; + return false; + } + return true; + } + @override void initState() { super.initState(); @@ -58,6 +71,14 @@ class _ClassCodeJoinPageState extends State { bool get _isComplete => _controllers.every((c) => c.text.isNotEmpty); + /// 清空所有输入框 + void _clearInputs() { + for (final c in _controllers) { + c.clear(); + } + _focusNodes[0].requestFocus(); + } + void _onChanged(int index, String value) { if (value.isEmpty && index > 0) { // 退格清空 → 跳到前一位 @@ -74,13 +95,48 @@ class _ClassCodeJoinPageState extends State { } void _submit() { - if (!_isComplete) return; + if (!_isComplete || _isCurrentlyLocked) return; context.read().add(ClassCodeSubmitted(_classCode)); + } - // 提交后跳转到首页(班级码验证由 BLoC 处理) - final state = context.read().state; - if (state is Authenticated && !state.needsClassCode) { + /// 处理 AuthBloc 状态变化 + void _handleAuthState(BuildContext context, AuthState state) { + if (state is! Authenticated) return; + + // 成功加入班级 + if (!state.needsClassCode) { context.go('/home'); + return; + } + + // 班级码验证错误 + final error = state.classCodeError; + if (error != null && error.isNotEmpty) { + _failedAttempts++; + + // 检查是否为锁定错误 + if (error.contains('30 分钟') || error.contains('尝试次数过多')) { + setState(() { + _lockoutEndTime = DateTime.now().add( + Duration(minutes: DesignTokens.classCodeLockoutMinutes), + ); + }); + } + + // 清空输入 + _clearInputs(); + + // 显示错误提示 + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 3), + ), + ); + } } } @@ -88,75 +144,136 @@ class _ClassCodeJoinPageState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.spacing24, - ), - child: Column( - children: [ - const Spacer(flex: 2), + return BlocListener( + listener: _handleAuthState, + child: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.spacing24, + ), + child: Column( + children: [ + const Spacer(flex: 2), - // 标题 - Icon( - Icons.groups_rounded, - size: 64, - color: colorScheme.primary, - ), - const SizedBox(height: DesignTokens.spacing24), - Text( - '加入班级', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: DesignTokens.spacing8), - Text( - '输入老师提供的 6 位班级码', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: DesignTokens.spacing48), - - // 6 位输入框 - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - DesignTokens.classCodeLength, - (index) => _buildCodeInput(context, index, colorScheme), + // 标题 + Icon( + Icons.groups_rounded, + size: 64, + color: colorScheme.primary, ), - ), - const SizedBox(height: DesignTokens.spacing24), + const SizedBox(height: DesignTokens.spacing24), + Text( + '加入班级', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: DesignTokens.spacing8), + Text( + '输入老师提供的 6 位班级码', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: DesignTokens.spacing48), - // 跳过按钮 - TextButton( - onPressed: () => context.go('/home'), - child: Text( - '稍后再加入', - style: TextStyle( - color: colorScheme.onSurface.withValues(alpha: 0.5), + // 锁定状态 or 输入框 + if (_isCurrentlyLocked) + _buildLockoutView(context, colorScheme) + else + _buildCodeInputs(context, colorScheme), + + const SizedBox(height: DesignTokens.spacing24), + + // 跳过按钮 + TextButton( + onPressed: () => context.go('/home'), + child: Text( + '稍后再加入', + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), ), ), - ), - const Spacer(flex: 3), - ], + // 剩余尝试次数提示 + if (!_isCurrentlyLocked && _failedAttempts > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '剩余 ${DesignTokens.classCodeMaxAttempts - _failedAttempts} 次尝试机会', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _failedAttempts >= 4 + ? colorScheme.error + : colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + + const Spacer(flex: 3), + ], + ), ), ), ), ); } + /// 锁定视图 — 超过最大尝试次数后显示 + Widget _buildLockoutView(BuildContext context, ColorScheme colorScheme) { + return Column( + children: [ + Icon(Icons.lock_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 16), + Text( + '尝试次数过多', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '请等待 ${DesignTokens.classCodeLockoutMinutes} 分钟后再试', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ); + } + + /// 6 位班级码输入框 + Widget _buildCodeInputs(BuildContext context, ColorScheme colorScheme) { + return BlocBuilder( + builder: (context, state) { + final isLoading = state is Authenticated && state.isLoading; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + DesignTokens.classCodeLength, + (index) => _buildCodeInput(context, index, colorScheme, isLoading), + ), + ); + }, + ); + } + /// 单个班级码输入框 - Widget _buildCodeInput(BuildContext context, int index, ColorScheme colorScheme) { + Widget _buildCodeInput( + BuildContext context, + int index, + ColorScheme colorScheme, + bool isLoading, + ) { return SizedBox( width: 48, height: 56, child: TextField( controller: _controllers[index], focusNode: _focusNodes[index], + enabled: !isLoading, textAlign: TextAlign.center, textAlignVertical: TextAlignVertical.center, style: Theme.of(context).textTheme.headlineSmall?.copyWith( diff --git a/app/lib/features/auth/views/login_page.dart b/app/lib/features/auth/views/login_page.dart index 1a9997e..3fa784a 100644 --- a/app/lib/features/auth/views/login_page.dart +++ b/app/lib/features/auth/views/login_page.dart @@ -11,6 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/design_tokens.dart'; +import '../../../core/theme/app_radius.dart'; import '../bloc/auth_bloc.dart'; /// 登录/注册页面 @@ -138,7 +139,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix height: 80, decoration: BoxDecoration( color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, ), child: Icon( Icons.edit_note_rounded, @@ -186,7 +187,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix hintText: '你想被叫什么名字?', prefixIcon: const Icon(Icons.face_rounded), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), ), textInputAction: TextInputAction.next, @@ -202,7 +203,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix hintText: _isRegister ? '设置一个账号名' : '输入你的账号', prefixIcon: const Icon(Icons.person_rounded), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), ), textInputAction: TextInputAction.next, @@ -237,7 +238,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix }, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), ), textInputAction: TextInputAction.done, @@ -268,7 +269,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix onPressed: isLoading ? null : _submit, style: FilledButton.styleFrom( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), ), child: isLoading diff --git a/app/lib/features/auth/views/role_selection_page.dart b/app/lib/features/auth/views/role_selection_page.dart index a50b109..2b0327d 100644 --- a/app/lib/features/auth/views/role_selection_page.dart +++ b/app/lib/features/auth/views/role_selection_page.dart @@ -11,6 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/design_tokens.dart'; +import '../../../core/theme/app_radius.dart'; import '../../../data/models/user.dart'; import '../bloc/auth_bloc.dart'; @@ -146,11 +147,11 @@ class _RoleCardWidget extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, child: Ink( decoration: BoxDecoration( color: role.color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, border: Border.all( color: role.color.withValues(alpha: 0.3), width: 2,