Files
nj/app/lib/features/auth/views/class_code_join_page.dart
iven 0fe3bc705c 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 通过
2026-06-01 01:22:53 +08:00

190 lines
5.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 班级码加入页面 — 学生/家长通过 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();
},
),
);
}
}