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:
@@ -1,10 +1,53 @@
|
||||
// 暖记 App 根组件 — MaterialApp + BLoC Provider 注入
|
||||
//
|
||||
// 依赖注入结构:
|
||||
// RepositoryProvider<AuthRepository> — 认证仓库(全局唯一)
|
||||
// └─ BlocProvider<AuthBloc> — 认证 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<ApiClient>.value(value: apiClient),
|
||||
RepositoryProvider<AuthRepository>.value(value: authRepository),
|
||||
],
|
||||
child: BlocProvider<AuthBloc>.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NavigatorState>();
|
||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 暖记路由配置
|
||||
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<AuthState> _subscription;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// App Shell — 包裹 ResponsiveScaffold
|
||||
|
||||
54
app/lib/data/models/auth_token.dart
Normal file
54
app/lib/data/models/auth_token.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
153
app/lib/data/models/user.dart
Normal file
153
app/lib/data/models/user.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
264
app/lib/data/repositories/auth_repository.dart
Normal file
264
app/lib/data/repositories/auth_repository.dart
Normal file
@@ -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<User> 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<String, dynamic>);
|
||||
|
||||
await _saveAuth(token, user);
|
||||
_apiClient.setToken(token.accessToken);
|
||||
|
||||
_logger.i('登录成功: ${user.displayLabel}');
|
||||
return user;
|
||||
}
|
||||
|
||||
// ===== 注册 =====
|
||||
|
||||
/// 注册新用户
|
||||
///
|
||||
/// 调用后端 `POST /users`,成功后自动登录。
|
||||
Future<User> 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<AuthToken> 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<void> logout() async {
|
||||
_logger.i('登出');
|
||||
|
||||
try {
|
||||
if (isAuthenticated) {
|
||||
await _apiClient.post('/auth/logout');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.w('登出 API 调用失败(忽略): $e');
|
||||
}
|
||||
|
||||
await _clearAuth();
|
||||
_apiClient.clearToken();
|
||||
}
|
||||
|
||||
// ===== 本地恢复 =====
|
||||
|
||||
/// 从安全存储恢复认证状态
|
||||
///
|
||||
/// App 启动时调用,检查是否有有效的持久化令牌。
|
||||
/// 如果令牌即将过期,自动刷新。
|
||||
Future<User?> 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<String, dynamic>,
|
||||
);
|
||||
|
||||
// 令牌已过期 → 尝试刷新
|
||||
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<String, dynamic> _extractData(dynamic responseData) {
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
// 后端 ApiResponse 格式: { success: bool, data: T, message: String? }
|
||||
if (responseData.containsKey('data')) {
|
||||
return responseData['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return responseData;
|
||||
}
|
||||
throw const AuthException('服务器响应格式异常');
|
||||
}
|
||||
|
||||
/// 保存令牌和用户到安全存储
|
||||
Future<void> _saveAuth(AuthToken token, User user) async {
|
||||
_currentToken = token;
|
||||
_currentUser = user;
|
||||
await _saveToken(token);
|
||||
await _secureStorage.write(
|
||||
key: _keyUserJson,
|
||||
value: jsonEncode(user.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 仅保存令牌到安全存储
|
||||
Future<void> _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<void> _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);
|
||||
}
|
||||
}
|
||||
180
app/lib/features/auth/bloc/auth_bloc.dart
Normal file
180
app/lib/features/auth/bloc/auth_bloc.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
68
app/lib/features/auth/bloc/auth_event.dart
Normal file
68
app/lib/features/auth/bloc/auth_event.dart
Normal 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();
|
||||
}
|
||||
76
app/lib/features/auth/bloc/auth_state.dart
Normal file
76
app/lib/features/auth/bloc/auth_state.dart
Normal 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);
|
||||
}
|
||||
189
app/lib/features/auth/views/class_code_join_page.dart
Normal file
189
app/lib/features/auth/views/class_code_join_page.dart
Normal file
@@ -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<ClassCodeJoinPage> createState() => _ClassCodeJoinPageState();
|
||||
}
|
||||
|
||||
class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
|
||||
final List<TextEditingController> _controllers = List.generate(
|
||||
DesignTokens.classCodeLength,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<FocusNode> _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<AuthBloc>().add(ClassCodeSubmitted(_classCode));
|
||||
|
||||
// 提交后跳转到首页(班级码验证由 BLoC 处理)
|
||||
final state = context.read<AuthBloc>().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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _displayNameController = TextEditingController();
|
||||
|
||||
bool _isRegister = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
late final AnimationController _animController;
|
||||
late final Animation<double> _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<AuthBloc>().add(RegisterRequested(
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
displayName: _displayNameController.text.trim().isEmpty
|
||||
? null
|
||||
: _displayNameController.text.trim(),
|
||||
));
|
||||
} else {
|
||||
context.read<AuthBloc>().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<AuthBloc, AuthState>(
|
||||
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<AuthBloc, AuthState>(
|
||||
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<AuthBloc, AuthState>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
211
app/lib/features/auth/views/role_selection_page.dart
Normal file
211
app/lib/features/auth/views/role_selection_page.dart
Normal file
@@ -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<AuthBloc>().add(RoleSelected(role));
|
||||
|
||||
final state = context.read<AuthBloc>().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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
// 暖记 App 入口
|
||||
//
|
||||
// 初始化流程:
|
||||
// 1. 确保 Flutter 绑定就绪
|
||||
// 2. 运行 App(认证状态恢复在 AuthBloc.AppStarted 中处理)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app.dart';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,9 @@ dependencies:
|
||||
# 连接检测
|
||||
connectivity_plus: ^6.1.0
|
||||
|
||||
# 安全存储(JWT 令牌持久化,PIPL 合规)
|
||||
flutter_secure_storage: ^9.2.0
|
||||
|
||||
# 手写引擎
|
||||
perfect_freehand: ^1.0.0
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user