From 0fe3bc705c8b382c7e6151bd61f0a2aab7c7688e Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 01:22:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=AE=9E=E7=8E=B0=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E6=A8=A1=E5=9D=97=20=E2=80=94=20F2=20Auth=20BLoC=20+?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95/=E6=B3=A8=E5=86=8C/=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E9=80=89=E6=8B=A9/=E7=8F=AD=E7=BA=A7=E7=A0=81=E5=8A=A0?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增文件 (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 通过 --- app/lib/app.dart | 45 ++- app/lib/core/routing/app_router.dart | 293 ++++++++++------ app/lib/data/models/auth_token.dart | 54 +++ app/lib/data/models/user.dart | 153 ++++++++ .../data/repositories/auth_repository.dart | 264 ++++++++++++++ app/lib/features/auth/bloc/auth_bloc.dart | 180 ++++++++++ app/lib/features/auth/bloc/auth_event.dart | 68 ++++ app/lib/features/auth/bloc/auth_state.dart | 76 ++++ .../auth/views/class_code_join_page.dart | 189 ++++++++++ app/lib/features/auth/views/login_page.dart | 326 +++++++++++++++++- .../auth/views/role_selection_page.dart | 211 ++++++++++++ app/lib/main.dart | 6 + app/pubspec.lock | 48 +++ app/pubspec.yaml | 3 + crates/erp-diary/src/service/class_service.rs | 2 +- 15 files changed, 1805 insertions(+), 113 deletions(-) create mode 100644 app/lib/data/models/auth_token.dart create mode 100644 app/lib/data/models/user.dart create mode 100644 app/lib/data/repositories/auth_repository.dart create mode 100644 app/lib/features/auth/bloc/auth_bloc.dart create mode 100644 app/lib/features/auth/bloc/auth_event.dart create mode 100644 app/lib/features/auth/bloc/auth_state.dart create mode 100644 app/lib/features/auth/views/class_code_join_page.dart create mode 100644 app/lib/features/auth/views/role_selection_page.dart diff --git a/app/lib/app.dart b/app/lib/app.dart index 89dd97c..5a83766 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -1,10 +1,53 @@ +// 暖记 App 根组件 — MaterialApp + BLoC Provider 注入 +// +// 依赖注入结构: +// RepositoryProvider — 认证仓库(全局唯一) +// └─ BlocProvider — 认证 BLoC(全局唯一) +// └─ MaterialApp.router — 路由(使用 auth 状态守卫) + import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + import 'core/theme/app_theme.dart'; import 'core/routing/app_router.dart'; +import 'data/remote/api_client.dart'; +import 'data/repositories/auth_repository.dart'; +import 'features/auth/bloc/auth_bloc.dart'; +/// 暖记 App — 根组件 class NuanjiApp extends StatelessWidget { const NuanjiApp({super.key}); + @override + Widget build(BuildContext context) { + // 创建全局依赖(App 生命周期内单例) + final apiClient = ApiClient(); + final authRepository = AuthRepository(apiClient: apiClient); + final authBloc = AuthBloc(authRepository: authRepository); + + // 启动时检查认证状态 + authBloc.add(const AppStarted()); + + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: apiClient), + RepositoryProvider.value(value: authRepository), + ], + child: BlocProvider.value( + value: authBloc, + child: _AppView(router: createAppRouter(authBloc)), + ), + ); + } +} + +/// App 视图 — MaterialApp.router 包装 +class _AppView extends StatelessWidget { + final GoRouter router; + + const _AppView({required this.router}); + @override Widget build(BuildContext context) { return MaterialApp.router( @@ -13,7 +56,7 @@ class NuanjiApp extends StatelessWidget { theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: ThemeMode.system, - routerConfig: appRouter, + routerConfig: router, ); } } diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 757d67a..bb69611 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -1,7 +1,15 @@ -// 暖记路由表 — go_router 20 页面 +// 暖记路由表 — go_router 20 页面 + 认证守卫 +// +// 路由守卫逻辑: +// - 未认证用户访问受保护路由 → 重定向到 /login +// - 已认证用户访问 /login → 重定向到 /home +// - 需要角色选择 → 重定向到 /role-selection +// - 需要班级码 → 重定向到 /class-code export '../../widgets/responsive_scaffold.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -14,123 +22,179 @@ import '../../features/search/views/search_page.dart'; import '../../features/profile/views/profile_page.dart'; import '../../features/editor/views/editor_page.dart'; import '../../features/auth/views/login_page.dart'; +import '../../features/auth/views/role_selection_page.dart'; +import '../../features/auth/views/class_code_join_page.dart'; import '../../features/class_/views/class_page.dart'; import '../../features/teacher/views/teacher_page.dart'; import '../../features/parent/views/parent_page.dart'; import '../../features/achievement/views/achievement_page.dart'; import '../../features/stickers/views/sticker_library_page.dart'; import '../../features/templates/views/template_gallery_page.dart'; +import '../../features/auth/bloc/auth_bloc.dart'; // Shell 分支键 final _rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); -/// 暖记路由配置 -final appRouter = GoRouter( - navigatorKey: _rootNavigatorKey, - initialLocation: '/home', - debugLogDiagnostics: true, - routes: [ - // 认证路由(无 Shell) - GoRoute( - path: '/login', - name: 'login', - builder: (context, state) => const LoginPage(), - ), +/// 不需要认证的白名单路径 +const _publicPaths = ['/login', '/role-selection', '/class-code']; - // 主 Shell 路由(底部导航 + 侧边导航) - ShellRoute( - navigatorKey: _shellNavigatorKey, - builder: (context, state, child) { - // 根据当前路径计算选中的 tab index - final index = _selectedIndexFromLocation(state.uri.path); - return _AppShell( - selectedIndex: index, - child: child, - ); - }, - routes: [ - // Tab 0: 首页日记流 - GoRoute( - path: '/home', - name: 'home', - builder: (context, state) => const HomePage(), - ), - // Tab 1: 日历 - GoRoute( - path: '/calendar', - name: 'calendar', - builder: (context, state) => const CalendarPage(), - ), - // Tab 2: 心情 - GoRoute( - path: '/mood', - name: 'mood', - builder: (context, state) => const MoodPage(), - ), - // Tab 3: 搜索 - GoRoute( - path: '/search', - name: 'search', - builder: (context, state) => const SearchPage(), - ), - // Tab 4: 个人中心 - GoRoute( - path: '/profile', - name: 'profile', - builder: (context, state) => const ProfilePage(), - ), - ], - ), +/// 创建路由配置 — 需要注入 AuthBloc +GoRouter createAppRouter(AuthBloc authBloc) { + return GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: '/home', + debugLogDiagnostics: true, - // 全屏页面(无底部导航) - GoRoute( - path: '/editor', - name: 'editor', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { - final journalId = state.uri.queryParameters['id']; - return EditorPage(journalId: journalId); - }, - ), - GoRoute( - path: '/class', - name: 'class', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const ClassPage(), - ), - GoRoute( - path: '/teacher', - name: 'teacher', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const TeacherPage(), - ), - GoRoute( - path: '/parent', - name: 'parent', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const ParentPage(), - ), - GoRoute( - path: '/achievements', - name: 'achievements', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const AchievementPage(), - ), - GoRoute( - path: '/stickers', - name: 'stickers', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const StickerLibraryPage(), - ), - GoRoute( - path: '/templates', - name: 'templates', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const TemplateGalleryPage(), - ), - ], -); + // ===== 认证路由守卫 ===== + redirect: (context, state) { + final authState = authBloc.state; + final currentPath = state.uri.path; + + // 加载中 → 不做重定向 + if (authState is AuthInitial || authState is AuthLoading) { + return null; + } + + final isAuthenticated = authState is Authenticated; + final isPublicPath = _publicPaths.contains(currentPath); + + // 已认证 + 访问公开页面 → 根据状态重定向 + if (isAuthenticated && isPublicPath) { + if (authState.needsRoleSelection) return '/role-selection'; + if (authState.needsClassCode) return '/class-code'; + return '/home'; + } + + // 已认证 + 访问受保护页面 → 检查是否需要额外步骤 + if (isAuthenticated) { + if (authState.needsRoleSelection && currentPath != '/role-selection') { + return '/role-selection'; + } + if (authState.needsClassCode && + currentPath != '/class-code' && + currentPath != '/role-selection') { + return '/class-code'; + } + return null; + } + + // 未认证 + 访问公开页面 → 放行 + if (isPublicPath) return null; + + // 未认证 + 访问受保护页面 → 重定向到登录 + return '/login'; + }, + + // 监听认证状态变化,自动触发重定向 + refreshListenable: _AuthListenable(authBloc), + + routes: [ + // 认证路由(无 Shell) + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/role-selection', + name: 'roleSelection', + builder: (context, state) => const RoleSelectionPage(), + ), + GoRoute( + path: '/class-code', + name: 'classCode', + builder: (context, state) => const ClassCodeJoinPage(), + ), + + // 主 Shell 路由(底部导航 + 侧边导航) + ShellRoute( + navigatorKey: _shellNavigatorKey, + builder: (context, state, child) { + final index = _selectedIndexFromLocation(state.uri.path); + return _AppShell( + selectedIndex: index, + child: child, + ); + }, + routes: [ + GoRoute( + path: '/home', + name: 'home', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: '/calendar', + name: 'calendar', + builder: (context, state) => const CalendarPage(), + ), + GoRoute( + path: '/mood', + name: 'mood', + builder: (context, state) => const MoodPage(), + ), + GoRoute( + path: '/search', + name: 'search', + builder: (context, state) => const SearchPage(), + ), + GoRoute( + path: '/profile', + name: 'profile', + builder: (context, state) => const ProfilePage(), + ), + ], + ), + + // 全屏页面(无底部导航) + GoRoute( + path: '/editor', + name: 'editor', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) { + final journalId = state.uri.queryParameters['id']; + return EditorPage(journalId: journalId); + }, + ), + GoRoute( + path: '/class', + name: 'class', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const ClassPage(), + ), + GoRoute( + path: '/teacher', + name: 'teacher', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const TeacherPage(), + ), + GoRoute( + path: '/parent', + name: 'parent', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const ParentPage(), + ), + GoRoute( + path: '/achievements', + name: 'achievements', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const AchievementPage(), + ), + GoRoute( + path: '/stickers', + name: 'stickers', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const StickerLibraryPage(), + ), + GoRoute( + path: '/templates', + name: 'templates', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const TemplateGalleryPage(), + ), + ], + ); +} /// 路径 → Tab index 映射 int _selectedIndexFromLocation(String location) { @@ -138,7 +202,24 @@ int _selectedIndexFromLocation(String location) { if (location.startsWith('/mood')) return 2; if (location.startsWith('/search')) return 3; if (location.startsWith('/profile')) return 4; - return 0; // 默认首页 + return 0; +} + +/// AuthBloc 变化监听器 — 驱动 GoRouter refreshListenable +class _AuthListenable extends ChangeNotifier { + _AuthListenable(AuthBloc authBloc) { + _subscription = authBloc.stream.listen((_) { + notifyListeners(); + }); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } } /// App Shell — 包裹 ResponsiveScaffold diff --git a/app/lib/data/models/auth_token.dart b/app/lib/data/models/auth_token.dart new file mode 100644 index 0000000..a4f88fd --- /dev/null +++ b/app/lib/data/models/auth_token.dart @@ -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 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 toJson() => { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_in': expiresIn, + 'expires_at': expiresAt.toIso8601String(), + }; + + /// 从持久化存储恢复(使用保存的过期时间) + factory AuthToken.fromStorage(Map 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)), + ); +} diff --git a/app/lib/data/models/user.dart b/app/lib/data/models/user.dart new file mode 100644 index 0000000..9c09845 --- /dev/null +++ b/app/lib/data/models/user.dart @@ -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 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 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 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? 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 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)) + .toList() ?? + [], + version: (json['version'] as int?) ?? 1, + ); + + Map 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, + }; +} diff --git a/app/lib/data/repositories/auth_repository.dart b/app/lib/data/repositories/auth_repository.dart new file mode 100644 index 0000000..d698dd6 --- /dev/null +++ b/app/lib/data/repositories/auth_repository.dart @@ -0,0 +1,264 @@ +// 认证仓库 — 登录/注册/令牌管理的统一入口 +// +// 职责: +// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出) +// - 通过 flutter_secure_storage 安全持久化 JWT 令牌(PIPL 合规) +// - 为 AuthBloc 提供干净的认证数据访问接口 + +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; + +import '../models/auth_token.dart'; +import '../models/user.dart'; +import '../remote/api_client.dart'; + +/// 安全存储键名 +const _keyAccessToken = 'auth_access_token'; +const _keyRefreshToken = 'auth_refresh_token'; +const _keyExpiresAt = 'auth_expires_at'; +const _keyUserJson = 'auth_user_json'; + +/// 认证异常 — 认证流程中出现的错误 +class AuthException implements Exception { + final String message; + final int? statusCode; + + const AuthException(this.message, {this.statusCode}); + + @override + String toString() => 'AuthException: $message'; +} + +/// 认证仓库 — 管理用户登录状态和令牌 +/// +/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。 +/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求。 +class AuthRepository { + final ApiClient _apiClient; + final FlutterSecureStorage _secureStorage; + final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0)); + + AuthToken? _currentToken; + User? _currentUser; + + AuthRepository({ + required ApiClient apiClient, + FlutterSecureStorage? secureStorage, + }) : _apiClient = apiClient, + _secureStorage = secureStorage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + /// 当前用户(可能为 null) + User? get currentUser => _currentUser; + + /// 当前令牌(可能为 null) + AuthToken? get currentToken => _currentToken; + + /// 是否已登录 + bool get isAuthenticated => _currentToken != null && !_currentToken!.isExpired; + + // ===== 登录 ===== + + /// 用户名密码登录 + /// + /// 调用后端 `POST /auth/login`,成功后保存令牌和用户信息。 + Future login({ + required String username, + required String password, + }) async { + _logger.i('登录请求: $username'); + + final response = await _apiClient.post('/auth/login', data: { + 'username': username, + 'password': password, + 'client_type': 'mobile', + }); + + final data = _extractData(response.data); + final token = AuthToken.fromJson(data); + final user = User.fromJson(data['user'] as Map); + + await _saveAuth(token, user); + _apiClient.setToken(token.accessToken); + + _logger.i('登录成功: ${user.displayLabel}'); + return user; + } + + // ===== 注册 ===== + + /// 注册新用户 + /// + /// 调用后端 `POST /users`,成功后自动登录。 + Future register({ + required String username, + required String password, + String? displayName, + }) async { + _logger.i('注册请求: $username'); + + await _apiClient.post('/users', data: { + 'username': username, + 'password': password, + if (displayName != null) 'display_name': displayName, + }); + + // 注册成功后自动登录 + return await login(username: username, password: password); + } + + // ===== 刷新令牌 ===== + + /// 刷新访问令牌 + /// + /// 调用后端 `POST /auth/refresh`,使用 refresh_token 获取新的 access_token。 + Future refreshToken() async { + if (_currentToken == null) { + throw const AuthException('无可用令牌,请重新登录'); + } + + _logger.d('刷新令牌'); + + final response = await _apiClient.post('/auth/refresh', data: { + 'refresh_token': _currentToken!.refreshToken, + }); + + final data = _extractData(response.data); + final token = AuthToken.fromJson(data); + + await _saveToken(token); + _apiClient.setToken(token.accessToken); + + return token; + } + + // ===== 登出 ===== + + /// 登出 + /// + /// 调用后端 `POST /auth/logout` 并清除本地存储。 + Future logout() async { + _logger.i('登出'); + + try { + if (isAuthenticated) { + await _apiClient.post('/auth/logout'); + } + } catch (e) { + _logger.w('登出 API 调用失败(忽略): $e'); + } + + await _clearAuth(); + _apiClient.clearToken(); + } + + // ===== 本地恢复 ===== + + /// 从安全存储恢复认证状态 + /// + /// App 启动时调用,检查是否有有效的持久化令牌。 + /// 如果令牌即将过期,自动刷新。 + Future restoreAuth() async { + _logger.d('恢复认证状态'); + + try { + final accessToken = await _secureStorage.read(key: _keyAccessToken); + final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken); + final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt); + final userJsonStr = await _secureStorage.read(key: _keyUserJson); + + if (accessToken == null || refreshTokenStr == null || userJsonStr == null) { + _logger.d('无存储的认证信息'); + return null; + } + + final expiresAt = DateTime.parse(expiresAtStr ?? DateTime.now().subtract(const Duration(hours: 1)).toIso8601String()); + + _currentToken = AuthToken( + accessToken: accessToken, + refreshToken: refreshTokenStr, + expiresIn: 0, + expiresAt: expiresAt, + ); + + _currentUser = User.fromJson( + jsonDecode(userJsonStr) as Map, + ); + + // 令牌已过期 → 尝试刷新 + if (_currentToken!.isExpired) { + _logger.d('令牌已过期,尝试刷新'); + try { + await refreshToken(); + } catch (e) { + _logger.w('令牌刷新失败,需要重新登录: $e'); + await _clearAuth(); + return null; + } + } else if (_currentToken!.isExpiringSoon) { + // 即将过期 → 后台刷新(不阻塞) + _logger.d('令牌即将过期,后台刷新'); + refreshToken().catchError((e) { + _logger.w('后台刷新失败: $e'); + return _currentToken!; // 返回当前令牌作为 fallback + }); + } + + _apiClient.setToken(_currentToken!.accessToken); + _logger.i('认证恢复成功: ${_currentUser!.displayLabel}'); + return _currentUser; + } catch (e) { + _logger.e('认证恢复失败: $e'); + await _clearAuth(); + return null; + } + } + + // ===== 私有方法 ===== + + /// 从 API 响应中提取 data 字段 + Map _extractData(dynamic responseData) { + if (responseData is Map) { + // 后端 ApiResponse 格式: { success: bool, data: T, message: String? } + if (responseData.containsKey('data')) { + return responseData['data'] as Map; + } + return responseData; + } + throw const AuthException('服务器响应格式异常'); + } + + /// 保存令牌和用户到安全存储 + Future _saveAuth(AuthToken token, User user) async { + _currentToken = token; + _currentUser = user; + await _saveToken(token); + await _secureStorage.write( + key: _keyUserJson, + value: jsonEncode(user.toJson()), + ); + } + + /// 仅保存令牌到安全存储 + Future _saveToken(AuthToken token) async { + _currentToken = token; + await _secureStorage.write(key: _keyAccessToken, value: token.accessToken); + await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken); + await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String()); + } + + /// 清除所有认证数据 + Future _clearAuth() async { + _currentToken = null; + _currentUser = null; + await _secureStorage.delete(key: _keyAccessToken); + await _secureStorage.delete(key: _keyRefreshToken); + await _secureStorage.delete(key: _keyExpiresAt); + await _secureStorage.delete(key: _keyUserJson); + } +} diff --git a/app/lib/features/auth/bloc/auth_bloc.dart b/app/lib/features/auth/bloc/auth_bloc.dart new file mode 100644 index 0000000..6db3ad1 --- /dev/null +++ b/app/lib/features/auth/bloc/auth_bloc.dart @@ -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 { + final AuthRepository _authRepository; + final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0)); + + AuthBloc({required AuthRepository authRepository}) + : _authRepository = authRepository, + super(const AuthInitial()) { + // 注册事件处理器 + on(_onAppStarted); + on(_onLoginRequested); + on(_onRegisterRequested); + on(_onRoleSelected); + on(_onClassCodeSubmitted); + on(_onLogoutRequested); + on(_onTokenRefreshed); + on(_onAuthExpired); + } + + /// App 启动 — 从本地存储恢复认证状态 + Future _onAppStarted( + AppStarted event, + Emitter 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 _onLoginRequested( + LoginRequested event, + Emitter 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 _onRegisterRequested( + RegisterRequested event, + Emitter 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 _onRoleSelected( + RoleSelected event, + Emitter 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 _onClassCodeSubmitted( + ClassCodeSubmitted event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is! Authenticated) return; + + // TODO: 调用后端 API 验证班级码并加入班级 + // 当前先标记为已完成,班级码验证在 F8 阶段完善 + emit(currentState.copyWith( + needsClassCode: false, + )); + + _logger.i('班级码加入: ${event.classCode}'); + } + + /// 用户登出 + Future _onLogoutRequested( + LogoutRequested event, + Emitter emit, + ) async { + try { + await _authRepository.logout(); + } catch (e) { + _logger.w('登出失败(忽略): $e'); + } + emit(const Unauthenticated()); + } + + /// 令牌刷新成功 + Future _onTokenRefreshed( + TokenRefreshed event, + Emitter emit, + ) async { + _logger.d('令牌已刷新'); + // 不改变当前状态,仅更新令牌 + } + + /// 认证过期(401 拦截器触发) + Future _onAuthExpired( + AuthExpired event, + Emitter emit, + ) async { + _logger.w('认证过期,需要重新登录'); + emit(const Unauthenticated()); + } +} diff --git a/app/lib/features/auth/bloc/auth_event.dart b/app/lib/features/auth/bloc/auth_event.dart new file mode 100644 index 0000000..0556cfe --- /dev/null +++ b/app/lib/features/auth/bloc/auth_event.dart @@ -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(); +} diff --git a/app/lib/features/auth/bloc/auth_state.dart b/app/lib/features/auth/bloc/auth_state.dart new file mode 100644 index 0000000..194912b --- /dev/null +++ b/app/lib/features/auth/bloc/auth_state.dart @@ -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); +} diff --git a/app/lib/features/auth/views/class_code_join_page.dart b/app/lib/features/auth/views/class_code_join_page.dart new file mode 100644 index 0000000..1ced3e7 --- /dev/null +++ b/app/lib/features/auth/views/class_code_join_page.dart @@ -0,0 +1,189 @@ +// 班级码加入页面 — 学生/家长通过 6 位码加入班级 +// +// 设计要点: +// - 6 位独立输入框,自动聚焦下一位 +// - 输入完成后自动提交验证 +// - 安全限制:5 次错误后锁定 30 分钟 +// - 友好的状态反馈(验证中/成功/失败) + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../bloc/auth_bloc.dart'; + +/// 班级码加入页面 +class ClassCodeJoinPage extends StatefulWidget { + const ClassCodeJoinPage({super.key}); + + @override + State createState() => _ClassCodeJoinPageState(); +} + +class _ClassCodeJoinPageState extends State { + final List _controllers = List.generate( + DesignTokens.classCodeLength, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate( + DesignTokens.classCodeLength, + (_) => FocusNode(), + ); + + @override + void initState() { + super.initState(); + // 自动聚焦第一个输入框 + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + }); + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + for (final f in _focusNodes) { + f.dispose(); + } + super.dispose(); + } + + /// 获取当前输入的班级码 + String get _classCode => _controllers.map((c) => c.text).join(); + + /// 是否所有位都已输入 + bool get _isComplete => + _controllers.every((c) => c.text.isNotEmpty); + + void _onChanged(int index, String value) { + if (value.isEmpty && index > 0) { + // 退格清空 → 跳到前一位 + _focusNodes[index - 1].requestFocus(); + } else if (value.isNotEmpty && index < DesignTokens.classCodeLength - 1) { + // 输入字符 → 跳到下一位 + _focusNodes[index + 1].requestFocus(); + } + + // 全部输入完成 → 自动提交 + if (_isComplete) { + _submit(); + } + } + + void _submit() { + if (!_isComplete) return; + context.read().add(ClassCodeSubmitted(_classCode)); + + // 提交后跳转到首页(班级码验证由 BLoC 处理) + final state = context.read().state; + if (state is Authenticated && !state.needsClassCode) { + context.go('/home'); + } + } + + @override + 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), + + // 标题 + 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), + ), + ), + 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), + ], + ), + ), + ), + ); + } + + /// 单个班级码输入框 + Widget _buildCodeInput(BuildContext context, int index, ColorScheme colorScheme) { + return SizedBox( + width: 48, + height: 56, + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0, + ), + maxLength: 1, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + ), + onChanged: (value) => _onChanged(index, value), + onSubmitted: (_) { + if (_isComplete) _submit(); + }, + ), + ); + } +} diff --git a/app/lib/features/auth/views/login_page.dart b/app/lib/features/auth/views/login_page.dart index 511c60b..1a9997e 100644 --- a/app/lib/features/auth/views/login_page.dart +++ b/app/lib/features/auth/views/login_page.dart @@ -1,13 +1,329 @@ -import 'package:flutter/material.dart'; +// 登录页面 — 用户名密码登录 + 注册切换 +// +// 设计要点: +// - 温暖治愈风格,使用珊瑚色主色调 +// - 表单验证友好提示(面向小学生,语言简单) +// - 密码可切换可见性 +// - 登录/注册模式平滑切换 -class LoginPage extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../bloc/auth_bloc.dart'; + +/// 登录/注册页面 +class LoginPage extends StatefulWidget { const LoginPage({super.key}); + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _displayNameController = TextEditingController(); + + bool _isRegister = false; + bool _obscurePassword = true; + + late final AnimationController _animController; + late final Animation _fadeAnim; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: DesignTokens.animNormal, + ); + _fadeAnim = CurvedAnimation( + parent: _animController, + curve: DesignTokens.warmCurve, + ); + _animController.forward(); + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _displayNameController.dispose(); + _animController.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + + if (_isRegister) { + context.read().add(RegisterRequested( + username: _usernameController.text.trim(), + password: _passwordController.text, + displayName: _displayNameController.text.trim().isEmpty + ? null + : _displayNameController.text.trim(), + )); + } else { + context.read().add(LoginRequested( + username: _usernameController.text.trim(), + password: _passwordController.text, + )); + } + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('登录 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return BlocListener( + listener: (context, state) { + if (state is Authenticated) { + if (state.needsRoleSelection) { + context.go('/role-selection'); + } else if (state.needsClassCode) { + context.go('/class-code'); + } else { + context.go('/home'); + } + } + }, + child: Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.spacing32, + ), + child: FadeTransition( + opacity: _fadeAnim, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeader(context, colorScheme), + const SizedBox(height: DesignTokens.spacing48), + _buildForm(context, theme, colorScheme), + const SizedBox(height: DesignTokens.spacing24), + _buildSubmitButton(context, colorScheme), + const SizedBox(height: DesignTokens.spacing16), + _buildModeToggle(context, colorScheme), + const SizedBox(height: DesignTokens.spacing32), + BlocBuilder( + builder: (context, state) { + if (state is AuthError) { + return _buildErrorMessage(state.message, colorScheme); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { + return Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(22), + ), + child: Icon( + Icons.edit_note_rounded, + size: 44, + color: colorScheme.primary, + ), + ), + const SizedBox(height: DesignTokens.spacing16), + Text( + '暖记', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + const SizedBox(height: DesignTokens.spacing4), + Text( + '记录温暖,书写成长', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ); + } + + Widget _buildForm(BuildContext context, ThemeData theme, ColorScheme colorScheme) { + return Form( + key: _formKey, + child: Column( + children: [ + AnimatedSize( + duration: DesignTokens.animNormal, + curve: DesignTokens.warmCurve, + child: AnimatedSwitcher( + duration: DesignTokens.animNormal, + child: _isRegister + ? Padding( + key: const ValueKey('display-name'), + padding: const EdgeInsets.only(bottom: DesignTokens.spacing16), + child: TextFormField( + controller: _displayNameController, + decoration: InputDecoration( + labelText: '昵称', + hintText: '你想被叫什么名字?', + prefixIcon: const Icon(Icons.face_rounded), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + textInputAction: TextInputAction.next, + ), + ) + : const SizedBox.shrink(key: ValueKey('display-name-hide')), + ), + ), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + labelText: '账号', + hintText: _isRegister ? '设置一个账号名' : '输入你的账号', + prefixIcon: const Icon(Icons.person_rounded), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入账号'; + } + if (value.trim().length < 3) { + return '账号至少需要 3 个字符'; + } + return null; + }, + ), + const SizedBox(height: DesignTokens.spacing16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: '密码', + hintText: _isRegister ? '设置一个密码' : '输入你的密码', + prefixIcon: const Icon(Icons.lock_rounded), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off_rounded + : Icons.visibility_rounded, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入密码'; + } + if (value.length < 6) { + return '密码至少需要 6 个字符'; + } + return null; + }, + ), + ], + ), + ); + } + + Widget _buildSubmitButton(BuildContext context, ColorScheme colorScheme) { + return BlocBuilder( + builder: (context, state) { + final isLoading = state is Authenticating; + return SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: isLoading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text( + _isRegister ? '注册' : '登录', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ); + }, + ); + } + + Widget _buildModeToggle(BuildContext context, ColorScheme colorScheme) { + return TextButton( + onPressed: () { + setState(() { + _isRegister = !_isRegister; + }); + _formKey.currentState?.reset(); + }, + child: Text( + _isRegister ? '已有账号?去登录' : '没有账号?去注册', + style: TextStyle(color: colorScheme.primary), + ), + ); + } + + Widget _buildErrorMessage(String message, ColorScheme colorScheme) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.spacing12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onErrorContainer), + const SizedBox(width: DesignTokens.spacing8), + Expanded( + child: Text( + message, + style: TextStyle(color: colorScheme.onErrorContainer, fontSize: 14), + ), + ), + ], ), ); } diff --git a/app/lib/features/auth/views/role_selection_page.dart b/app/lib/features/auth/views/role_selection_page.dart new file mode 100644 index 0000000..a50b109 --- /dev/null +++ b/app/lib/features/auth/views/role_selection_page.dart @@ -0,0 +1,211 @@ +// 角色选择页面 — 注册后选择身份角色 +// +// 暖记四种角色: +// - 🎓 老师 — 创建班级、布置主题、点评日记 +// - ✏️ 学生 — 加入班级、写日记、查看点评 +// - 👨‍👩‍👧 家长 — 查看孩子日记、管理数据 +// - 📖 独立用户 — 个人日记、不加入班级 + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../../../data/models/user.dart'; +import '../bloc/auth_bloc.dart'; + +/// 角色卡片数据 +class _RoleCard { + final UserRoleType type; + final String title; + final String subtitle; + final IconData icon; + final Color color; + + const _RoleCard({ + required this.type, + required this.title, + required this.subtitle, + required this.icon, + required this.color, + }); +} + +/// 角色选择页面 +class RoleSelectionPage extends StatelessWidget { + const RoleSelectionPage({super.key}); + + static const _roles = [ + _RoleCard( + type: UserRoleType.student, + title: '我是学生', + subtitle: '加入班级,记录每一天', + icon: Icons.school_rounded, + color: Color(0xFF81B29A), // 鼠尾草绿 + ), + _RoleCard( + type: UserRoleType.teacher, + title: '我是老师', + subtitle: '创建班级,陪伴学生成长', + icon: Icons.auto_stories_rounded, + color: Color(0xFFE07A5F), // 珊瑚色 + ), + _RoleCard( + type: UserRoleType.parent, + title: '我是家长', + subtitle: '关注孩子的成长记录', + icon: Icons.family_restroom_rounded, + color: Color(0xFFF2CC8F), // 暖金 + ), + _RoleCard( + type: UserRoleType.independent, + title: '独立使用', + subtitle: '个人日记,随心记录', + icon: Icons.menu_book_rounded, + color: Color(0xFFD4A5A5), // 玫瑰粉 + ), + ]; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.spacing24, + vertical: DesignTokens.spacing32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + '你好!👋', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: DesignTokens.spacing8), + Text( + '告诉我你的身份,我会为你定制体验', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: DesignTokens.spacing32), + + // 角色卡片网格 + Expanded( + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: DesignTokens.spacing16, + crossAxisSpacing: DesignTokens.spacing16, + childAspectRatio: 0.85, + children: _roles.map((role) => _RoleCardWidget( + role: role, + onTap: () => _selectRole(context, role.type), + )).toList(), + ), + ), + ], + ), + ), + ), + ); + } + + void _selectRole(BuildContext context, UserRoleType role) { + context.read().add(RoleSelected(role)); + + final state = context.read().state; + if (state is Authenticated) { + if (state.needsClassCode) { + context.go('/class-code'); + } else { + context.go('/home'); + } + } + } +} + +/// 角色卡片组件 +class _RoleCardWidget extends StatelessWidget { + final _RoleCard role; + final VoidCallback onTap; + + const _RoleCardWidget({ + required this.role, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: Ink( + decoration: BoxDecoration( + color: role.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: role.color.withValues(alpha: 0.3), + width: 2, + ), + ), + child: Padding( + padding: const EdgeInsets.all(DesignTokens.spacing16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 图标 + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: role.color.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon( + role.icon, + size: 28, + color: role.color, + ), + ), + const SizedBox(height: DesignTokens.spacing12), + + // 标题 + Text( + role.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: role.color, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: DesignTokens.spacing4), + + // 副标题 + Text( + role.subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 196795e..56d91fe 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,3 +1,9 @@ +// 暖记 App 入口 +// +// 初始化流程: +// 1. 确保 Flutter 绑定就绪 +// 2. 运行 App(认证状态恢复在 AuthBloc.AppStarted 中处理) + import 'package:flutter/material.dart'; import 'app.dart'; diff --git a/app/pubspec.lock b/app/pubspec.lock index 1e97e57..7d8d956 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -366,6 +366,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0e57184..00bf3db 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -30,6 +30,9 @@ dependencies: # 连接检测 connectivity_plus: ^6.1.0 + # 安全存储(JWT 令牌持久化,PIPL 合规) + flutter_secure_storage: ^9.2.0 + # 手写引擎 perfect_freehand: ^1.0.0 diff --git a/crates/erp-diary/src/service/class_service.rs b/crates/erp-diary/src/service/class_service.rs index 43eaf71..11f6e24 100644 --- a/crates/erp-diary/src/service/class_service.rs +++ b/crates/erp-diary/src/service/class_service.rs @@ -2,7 +2,7 @@ use chrono::{Months, Utc}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, }; use uuid::Uuid;