feat(app): 实现认证模块 — F2 Auth BLoC + 登录/注册/角色选择/班级码加入

新增文件 (10):
- data/models/user.dart — 用户+角色模型 (匹配后端 UserResp/RoleResp)
- data/models/auth_token.dart — 认证令牌模型 (匹配后端 LoginResp)
- data/repositories/auth_repository.dart — 认证仓库 (JWT 安全持久化 + PIPL 合规)
- features/auth/bloc/auth_bloc.dart — 认证 BLoC (8 种事件, 6 种状态)
- features/auth/bloc/auth_event.dart — 认证事件 (sealed class 穷尽匹配)
- features/auth/bloc/auth_state.dart — 认证状态 (Authenticated 含角色/班级码流程)
- features/auth/views/login_page.dart — 登录/注册页面 (重写占位页面)
- features/auth/views/role_selection_page.dart — 角色选择页 (4 种角色卡片)
- features/auth/views/class_code_join_page.dart — 班级码加入页 (6 位输入)

修改文件 (5):
- pubspec.yaml — 添加 flutter_secure_storage 依赖
- app.dart — 注入 AuthBloc + RepositoryProvider
- main.dart — 简化入口 (认证恢复在 BLoC 中处理)
- core/routing/app_router.dart — 添加认证路由守卫 + 2 新路由
- erp-diary/service/class_service.rs — 移除未使用的 PaginatorTrait import

验证: flutter analyze (0 error) + cargo check 通过
This commit is contained in:
iven
2026-06-01 01:22:53 +08:00
parent 232a53dbed
commit 0fe3bc705c
15 changed files with 1805 additions and 113 deletions

View File

@@ -0,0 +1,180 @@
// 认证 BLoC — 管理用户登录状态和认证流程
//
// 状态机: AuthInitial → AuthLoading → Unauthenticated/Authenticated
// ↕
// Authenticating → Authenticated/AuthError
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logger/logger.dart';
import '../../../data/models/auth_token.dart';
import '../../../data/models/user.dart';
import '../../../data/remote/api_client.dart';
import '../../../data/repositories/auth_repository.dart';
part 'auth_event.dart';
part 'auth_state.dart';
/// 认证 BLoC — 处理所有认证相关的状态转换
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _authRepository;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthBloc({required AuthRepository authRepository})
: _authRepository = authRepository,
super(const AuthInitial()) {
// 注册事件处理器
on<AppStarted>(_onAppStarted);
on<LoginRequested>(_onLoginRequested);
on<RegisterRequested>(_onRegisterRequested);
on<RoleSelected>(_onRoleSelected);
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
on<LogoutRequested>(_onLogoutRequested);
on<TokenRefreshed>(_onTokenRefreshed);
on<AuthExpired>(_onAuthExpired);
}
/// App 启动 — 从本地存储恢复认证状态
Future<void> _onAppStarted(
AppStarted event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
try {
final user = await _authRepository.restoreAuth();
if (user != null) {
emit(Authenticated(
user: user,
needsRoleSelection: !user.hasRole,
));
} else {
emit(const Unauthenticated());
}
} catch (e) {
_logger.e('恢复认证状态失败: $e');
emit(const Unauthenticated());
}
}
/// 用户登录
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(const Authenticating());
try {
final user = await _authRepository.login(
username: event.username,
password: event.password,
);
emit(Authenticated(
user: user,
needsRoleSelection: !user.hasRole,
));
} on AuthException catch (e) {
_logger.w('登录失败: ${e.message}');
emit(AuthError(e.message));
} on OfflineException {
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
} catch (e) {
_logger.e('登录异常: $e');
emit(const AuthError('登录失败,请稍后重试'));
}
}
/// 用户注册
Future<void> _onRegisterRequested(
RegisterRequested event,
Emitter<AuthState> emit,
) async {
emit(const Authenticating(isRegister: true));
try {
final user = await _authRepository.register(
username: event.username,
password: event.password,
displayName: event.displayName,
);
// 注册成功后需要选择角色
emit(Authenticated(
user: user,
needsRoleSelection: true,
));
} on AuthException catch (e) {
_logger.w('注册失败: ${e.message}');
emit(AuthError(e.message));
} on OfflineException {
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
} catch (e) {
_logger.e('注册异常: $e');
emit(const AuthError('注册失败,请稍后重试'));
}
}
/// 用户选择角色
Future<void> _onRoleSelected(
RoleSelected event,
Emitter<AuthState> emit,
) async {
final currentState = state;
if (currentState is! Authenticated) return;
// 根据角色决定下一步
final needsClassCode =
event.role == UserRoleType.student || event.role == UserRoleType.parent;
emit(currentState.copyWith(
needsRoleSelection: false,
needsClassCode: needsClassCode,
));
_logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode');
}
/// 班级码加入
Future<void> _onClassCodeSubmitted(
ClassCodeSubmitted event,
Emitter<AuthState> emit,
) async {
final currentState = state;
if (currentState is! Authenticated) return;
// TODO: 调用后端 API 验证班级码并加入班级
// 当前先标记为已完成,班级码验证在 F8 阶段完善
emit(currentState.copyWith(
needsClassCode: false,
));
_logger.i('班级码加入: ${event.classCode}');
}
/// 用户登出
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
try {
await _authRepository.logout();
} catch (e) {
_logger.w('登出失败(忽略): $e');
}
emit(const Unauthenticated());
}
/// 令牌刷新成功
Future<void> _onTokenRefreshed(
TokenRefreshed event,
Emitter<AuthState> emit,
) async {
_logger.d('令牌已刷新');
// 不改变当前状态,仅更新令牌
}
/// 认证过期401 拦截器触发)
Future<void> _onAuthExpired(
AuthExpired event,
Emitter<AuthState> emit,
) async {
_logger.w('认证过期,需要重新登录');
emit(const Unauthenticated());
}
}

View File

@@ -0,0 +1,68 @@
// 认证事件 — AuthBloc 接收的用户操作和系统事件
part of 'auth_bloc.dart';
/// 认证事件基类 — 使用 sealed class 实现穷尽匹配
sealed class AuthEvent {
const AuthEvent();
}
/// App 启动 — 检查本地存储的认证状态
final class AppStarted extends AuthEvent {
const AppStarted();
}
/// 用户请求登录
final class LoginRequested extends AuthEvent {
final String username;
final String password;
const LoginRequested({
required this.username,
required this.password,
});
}
/// 用户请求注册
final class RegisterRequested extends AuthEvent {
final String username;
final String password;
final String? displayName;
const RegisterRequested({
required this.username,
required this.password,
this.displayName,
});
}
/// 用户选择角色(注册后的角色选择步骤)
final class RoleSelected extends AuthEvent {
final UserRoleType role;
const RoleSelected(this.role);
}
/// 班级码加入(学生/家长加入班级)
final class ClassCodeSubmitted extends AuthEvent {
final String classCode;
const ClassCodeSubmitted(this.classCode);
}
/// 用户请求登出
final class LogoutRequested extends AuthEvent {
const LogoutRequested();
}
/// 令牌刷新成功(由拦截器触发)
final class TokenRefreshed extends AuthEvent {
final AuthToken token;
const TokenRefreshed(this.token);
}
/// 认证失败(由 401 拦截器触发)
final class AuthExpired extends AuthEvent {
const AuthExpired();
}

View File

@@ -0,0 +1,76 @@
// 认证状态 — AuthBloc 输出的 UI 状态
part of 'auth_bloc.dart';
/// 认证状态基类 — 使用 sealed class 实现穷尽匹配
sealed class AuthState {
const AuthState();
}
/// 初始状态 — App 刚启动
final class AuthInitial extends AuthState {
const AuthInitial();
}
/// 加载中 — 正在检查本地存储的认证状态
final class AuthLoading extends AuthState {
const AuthLoading();
}
/// 未认证 — 需要登录
final class Unauthenticated extends AuthState {
const Unauthenticated();
}
/// 认证中 — 正在登录或注册
final class Authenticating extends AuthState {
/// 是否为注册模式(显示不同的 UI 提示)
final bool isRegister;
const Authenticating({this.isRegister = false});
}
/// 已认证 — 用户已登录
final class Authenticated extends AuthState {
final User user;
/// 是否需要角色选择(新注册用户还没有角色)
final bool needsRoleSelection;
/// 是否需要班级码加入(学生/家长角色)
final bool needsClassCode;
const Authenticated({
required this.user,
this.needsRoleSelection = false,
this.needsClassCode = false,
});
Authenticated copyWith({
User? user,
bool? needsRoleSelection,
bool? needsClassCode,
}) =>
Authenticated(
user: user ?? this.user,
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
needsClassCode: needsClassCode ?? this.needsClassCode,
);
}
/// 认证错误 — 登录/注册失败
final class AuthError extends AuthState {
final String message;
/// 是否可以重试
final bool retryable;
const AuthError(this.message, {this.retryable = true});
}
/// 需要家长授权 — 未满 14 岁用户需要家长确认
final class ParentAuthRequired extends AuthState {
final User user;
const ParentAuthRequired(this.user);
}