feat(auth): 实现注册模块与登录页优化
1. 新增用户注册接口与页面,对齐Vue端业务逻辑 2. 新增居中Toast组件,替换原有SnackBar轻提示 3. 完善登录历史本地存储与下拉选择交互 4. 新增审核模式接口适配,动态控制注册入口显示 5. 优化登录页UI布局与交互细节
This commit is contained in:
@@ -97,6 +97,46 @@ class UserApi {
|
||||
await DioClient.instance.post('/api/v1/User/CloseAccount');
|
||||
}
|
||||
|
||||
// ==================== 注册 ====================
|
||||
|
||||
/// 注册 → POST /api/v1/User/Register
|
||||
///
|
||||
/// 对齐 Vue api/login `register`: { account, password },密码同样 MD5 加密
|
||||
/// Vue RegisterResponse = { code, message }(业务响应壳),Flutter 端 DioClient
|
||||
/// 已自动处理成功/失败分支,成功不关心 body,失败直接抛 ApiException。
|
||||
static Future<void> register({
|
||||
required String account,
|
||||
required String password,
|
||||
}) async {
|
||||
await DioClient.instance.post(
|
||||
'/api/v1/User/Register',
|
||||
data: {'account': account.trim(), 'password': _md5(password.trim())},
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 审核模式 ====================
|
||||
|
||||
/// 获取审核模式(决定是否展示"去注册"入口)→ GET /api/v1/Public/GetIsAuditMode
|
||||
///
|
||||
/// 对齐 Vue api/login: `getAuditMode()`,返回值本身就是 boolean
|
||||
/// (request 拦截器已解包),接口不可用时安全回退 false(不展示注册入口)。
|
||||
static Future<bool> getAuditMode() async {
|
||||
try {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Public/GetIsAuditMode',
|
||||
);
|
||||
if (data is bool) return data;
|
||||
if (data is Map) {
|
||||
// 兼容 { isAuditMode: true } 包装
|
||||
final v = data['isAuditMode'];
|
||||
if (v is bool) return v;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// MD5 加密(UniApp stringToMd5 的等价实现)
|
||||
static String _md5(String text) {
|
||||
return md5.convert(utf8.encode(text)).toString();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【CenterToast】居中轻量提示 —— 对标 uni.showToast(icon: 'none')
|
||||
///
|
||||
/// 一、适用场景:
|
||||
/// 表单校验失败("请填写账号"/"请填写密码")、轻量成功提示等
|
||||
/// 不需要用户确认、1.2s 自动消失、不阻塞交互(IgnorePointer)。
|
||||
///
|
||||
/// 二、与 SnackBar / ConfirmBox 的分工:
|
||||
/// CenterToast → 短暂轻量提示,自动消失,中间弹出
|
||||
/// SnackBar → 底部提示(信息可能较长),可手动关闭
|
||||
/// ConfirmBox → 需要用户决策的确认框,双按钮
|
||||
///
|
||||
/// 三、用法:
|
||||
/// CenterToast.show(context, '请填写账号');
|
||||
/// ============================================================
|
||||
class CenterToast {
|
||||
CenterToast._();
|
||||
|
||||
static OverlayEntry? _current;
|
||||
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String msg, {
|
||||
Duration duration = const Duration(milliseconds: 1200),
|
||||
}) {
|
||||
// 连续调用时先移除上一个,避免叠加
|
||||
_current?.remove();
|
||||
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Text(
|
||||
msg,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
_current = entry;
|
||||
Overlay.of(context).insert(entry);
|
||||
Future<void>.delayed(duration, () {
|
||||
if (entry.mounted && _current == entry) {
|
||||
entry.remove();
|
||||
_current = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:new_learn/features/auth/legal_document_page.dart';
|
||||
import 'package:new_learn/features/auth/legal_documents.dart';
|
||||
import 'package:new_learn/features/auth/login_page.dart';
|
||||
import 'package:new_learn/features/auth/privacy_consent_page.dart';
|
||||
import 'package:new_learn/features/auth/register_page.dart';
|
||||
import 'package:new_learn/features/class/all_student_view.dart';
|
||||
import 'package:new_learn/features/class/class_page.dart';
|
||||
import 'package:new_learn/features/class/my_interact_page.dart';
|
||||
@@ -145,8 +146,9 @@ final GoRouter appRouter = GoRouter(
|
||||
content: kPrivacyPolicyContent,
|
||||
),
|
||||
),
|
||||
// ---------- 登录页 ----------
|
||||
// ---------- 登录/注册页(未登录可访问) ----------
|
||||
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
|
||||
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
|
||||
// app_router.dart 的 routes 里加,不用管跳转逻辑
|
||||
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
|
||||
// ---------- 以后新业务页在这里加,一行一个 ----------
|
||||
@@ -154,7 +156,7 @@ final GoRouter appRouter = GoRouter(
|
||||
);
|
||||
|
||||
/// 登录守卫(对标 vue-router beforeEach):
|
||||
/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页
|
||||
/// 没登录 → 强制去 /login;已登录还访问 /login 或 /register → 踢回首页
|
||||
String? _guardLogin(BuildContext context, GoRouterState state) {
|
||||
final acceptedPrivacy = privacyAcceptedNotifier.value;
|
||||
final loggedIn = DioClient.instance.accessToken != null;
|
||||
@@ -163,11 +165,14 @@ String? _guardLogin(BuildContext context, GoRouterState state) {
|
||||
state.matchedLocation == '/user-agreement' ||
|
||||
state.matchedLocation == '/privacy-policy';
|
||||
final atLogin = state.matchedLocation == '/login';
|
||||
final atRegister = state.matchedLocation == '/register';
|
||||
// 未登录可白名单:隐私协议页 / 协议文档 / 登录页 / 注册页
|
||||
final atAuthFree = atLogin || atRegister || atLegalDocument;
|
||||
if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) {
|
||||
return '/privacy-consent';
|
||||
}
|
||||
if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login';
|
||||
if (!loggedIn && !atLogin && !atLegalDocument) return '/login';
|
||||
if (loggedIn && atLogin) return '/';
|
||||
if (!loggedIn && !atAuthFree && !atPrivacyConsent) return '/login';
|
||||
if (loggedIn && (atLogin || atRegister)) return '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,44 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【LoginHistoryItem】登录历史单条记录 —— 对齐 Vue loginHistory
|
||||
///
|
||||
/// account → 登录账号(唯一标识,重复登录时去重用)
|
||||
/// userName → 登录后拿到的昵称(展示用,可能为空)
|
||||
/// password → 记住的密码(明文,存在 secure_storage 里一条 JSON)
|
||||
/// 空串 "" 表示本次没勾选记住密码
|
||||
/// rememberPassword → 是否勾选了记住密码(决定密码是否回填)
|
||||
/// ============================================================
|
||||
class LoginHistoryItem {
|
||||
const LoginHistoryItem({
|
||||
required this.account,
|
||||
required this.userName,
|
||||
required this.password,
|
||||
required this.rememberPassword,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userName;
|
||||
final String password;
|
||||
final bool rememberPassword;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'account': account,
|
||||
'userName': userName,
|
||||
'password': password,
|
||||
'rememberPassword': rememberPassword,
|
||||
};
|
||||
|
||||
factory LoginHistoryItem.fromJson(Map<String, dynamic> json) =>
|
||||
LoginHistoryItem(
|
||||
account: json['account'] as String? ?? '',
|
||||
userName: json['userName'] as String? ?? '',
|
||||
password: json['password'] as String? ?? '',
|
||||
rememberPassword: json['rememberPassword'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存)
|
||||
///
|
||||
@@ -118,6 +156,36 @@ class UserStorage {
|
||||
Future<void> clearRememberedPassword() =>
|
||||
_secure.delete(key: _rememberedPasswordKey);
|
||||
|
||||
// ---------- 登录历史(多条,最多 3 条,对齐 Vue loginHistory) ----------
|
||||
|
||||
static const String _loginHistoryKey = 'login_history';
|
||||
static const int _maxHistoryCount = 3;
|
||||
|
||||
/// 读取登录历史(没存过返回空列表)
|
||||
Future<List<LoginHistoryItem>> readLoginHistory() async {
|
||||
final raw = await _secure.read(key: _loginHistoryKey);
|
||||
if (raw == null) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => LoginHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return []; // 缓存损坏时当没历史处理
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存登录历史(整体替换存,最多保留 3 条)
|
||||
Future<void> saveLoginHistory(List<LoginHistoryItem> items) async {
|
||||
final trimmed = items.length > _maxHistoryCount
|
||||
? items.sublist(0, _maxHistoryCount)
|
||||
: items;
|
||||
await _secure.write(
|
||||
key: _loginHistoryKey,
|
||||
value: jsonEncode(trimmed.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 一键退出登录:清 Token + 用户信息
|
||||
/// (记住的账号密码保留,方便用户下次登录)
|
||||
Future<void> clearAll() async {
|
||||
|
||||
Reference in New Issue
Block a user