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,54 @@
// 认证令牌模型 — 匹配后端 LoginResp
//
// 管理访问令牌和刷新令牌,支持自动计算过期时间。
// 令牌通过 flutter_secure_storage 安全持久化PIPL 合规要求)。
/// 认证令牌 — 包含访问令牌、刷新令牌和过期信息
class AuthToken {
final String accessToken;
final String refreshToken;
final int expiresIn;
final DateTime expiresAt;
const AuthToken({
required this.accessToken,
required this.refreshToken,
required this.expiresIn,
required this.expiresAt,
});
/// 令牌是否已过期
bool get isExpired => DateTime.now().isAfter(expiresAt);
/// 令牌是否即将过期5 分钟内)
bool get isExpiringSoon =>
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 5)));
/// 从后端 LoginResp JSON 创建
factory AuthToken.fromJson(Map<String, dynamic> json) {
final expiresIn = (json['expires_in'] as int?) ?? 3600;
return AuthToken(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
expiresIn: expiresIn,
expiresAt: DateTime.now().add(Duration(seconds: expiresIn)),
);
}
Map<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_in': expiresIn,
'expires_at': expiresAt.toIso8601String(),
};
/// 从持久化存储恢复(使用保存的过期时间)
factory AuthToken.fromStorage(Map<String, dynamic> json) => AuthToken(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
expiresIn: (json['expires_in'] as int?) ?? 3600,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: DateTime.now().add(const Duration(hours: 1)),
);
}

View File

@@ -0,0 +1,153 @@
// 用户数据模型 — 匹配后端 UserResp / RoleResp
//
// 暖记用户包含四种角色:老师、学生、家长、独立用户。
// 角色决定用户可访问的功能模块和页面。
/// 用户角色枚举 — 对应后端 role code
enum UserRoleType {
teacher('teacher'),
student('student'),
parent('parent'),
independent('independent');
const UserRoleType(this.code);
final String code;
/// 从后端角色代码解析,未知代码默认为独立用户
static UserRoleType fromCode(String code) => UserRoleType.values.firstWhere(
(r) => r.code == code,
orElse: () => UserRoleType.independent,
);
}
/// 角色信息 — 匹配后端 RoleResp
class UserRole {
final String id;
final String name;
final String code;
final String? description;
final bool isSystem;
final int version;
const UserRole({
required this.id,
required this.name,
required this.code,
this.description,
this.isSystem = false,
this.version = 1,
});
/// 获取标准化的角色类型
UserRoleType get type => UserRoleType.fromCode(code);
factory UserRole.fromJson(Map<String, dynamic> json) => UserRole(
id: json['id'] as String,
name: json['name'] as String,
code: json['code'] as String,
description: json['description'] as String?,
isSystem: (json['is_system'] as bool?) ?? false,
version: (json['version'] as int?) ?? 1,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'code': code,
'description': description,
'is_system': isSystem,
'version': version,
};
}
/// 用户信息 — 匹配后端 UserResp
///
/// 包含用户基本信息和角色列表。
/// 角色由后端 RBAC 系统管理,前端据此控制页面可见性和功能访问。
class User {
final String id;
final String username;
final String? email;
final String? phone;
final String? displayName;
final String? avatarUrl;
final String status;
final List<UserRole> roles;
final int version;
const User({
required this.id,
required this.username,
this.email,
this.phone,
this.displayName,
this.avatarUrl,
this.status = 'active',
this.roles = const [],
this.version = 1,
});
User copyWith({
String? displayName,
String? avatarUrl,
List<UserRole>? roles,
int? version,
}) =>
User(
id: id,
username: username,
email: email,
phone: phone,
displayName: displayName ?? this.displayName,
avatarUrl: avatarUrl ?? this.avatarUrl,
status: status,
roles: roles ?? this.roles,
version: version ?? this.version,
);
/// 获取用户的主角色类型(第一个角色的类型)
UserRoleType get primaryRoleType =>
roles.isNotEmpty ? roles.first.type : UserRoleType.independent;
/// 用户是否为老师
bool get isTeacher => roles.any((r) => r.type == UserRoleType.teacher);
/// 用户是否为学生
bool get isStudent => roles.any((r) => r.type == UserRoleType.student);
/// 用户是否为家长
bool get isParent => roles.any((r) => r.type == UserRoleType.parent);
/// 用户是否已完成角色选择
bool get hasRole => roles.isNotEmpty;
/// 显示名称:优先使用 displayName回退到 username
String get displayLabel => displayName ?? username;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
username: json['username'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
displayName: json['display_name'] as String?,
avatarUrl: json['avatar_url'] as String?,
status: (json['status'] as String?) ?? 'active',
roles: (json['roles'] as List?)
?.map((r) => UserRole.fromJson(r as Map<String, dynamic>))
.toList() ??
[],
version: (json['version'] as int?) ?? 1,
);
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'email': email,
'phone': phone,
'display_name': displayName,
'avatar_url': avatarUrl,
'status': status,
'roles': roles.map((r) => r.toJson()).toList(),
'version': version,
};
}