4 Commits
Author SHA1 Message Date
崔宝新 955d6455d8 feat(auth): 实现注册模块与登录页优化
1. 新增用户注册接口与页面,对齐Vue端业务逻辑
2. 新增居中Toast组件,替换原有SnackBar轻提示
3. 完善登录历史本地存储与下拉选择交互
4. 新增审核模式接口适配,动态控制注册入口显示
5. 优化登录页UI布局与交互细节
2026-09-08 17:12:26 +08:00
崔宝新 f718749ad7 Merge branch 'feat-cbx' 2026-09-08 15:36:38 +08:00
崔宝新 9017ec669f Merge branch 'feat-cbx' 2026-09-08 13:46:04 +08:00
崔宝新 151ae5afb9 提交 2026-09-08 13:45:13 +08:00
7 changed files with 1382 additions and 264 deletions
+234
View File
@@ -0,0 +1,234 @@
---
name: "vue3-to-flutter"
description: "把 Vue3/UniApp 代码迁移成 Flutter/Dart。当用户说迁移、翻译 Vue 页面、写 Flutter 对应功能时触发。"
---
# Vue3/UniApp → Flutter 迁移助手
## 项目定位
老项目(Vue3 + UniApp)和新项目(Flutter)在同一个根目录下:
| 项目 | 路径 |
|------|------|
| Vue3 老项目 | `d:\chongqing_yuanxuan\App.K12.Student.Miniapp\App.K12.Student.Miniapp` |
| Flutter 新项目 | `d:\chongqing_yuanxuan\App.K12.Student.Miniapp_flutter` |
**迁移时,Flutter 项目是工作目录,Vue 项目是参考目录。** 读 Vue 代码做参考,写 Flutter 代码到新项目。
---
## 一、目录结构映射
Vue 端和 Flutter 端的目录对应关系必须严格遵守:
| Vue (UniApp) | Flutter | 说明 |
|---|---|---|
| `pages/tabbar/my/my.vue` | `lib/features/my/my_page.dart` | tab 页面放 features 顶层 |
| `pages/tabbar/class/components/rankItem.vue` | `lib/features/class/widgets/...dart` | 模块私有组件放 features/[模块]/widgets/ |
| `components/ConfirmBox/` | `lib/core/components/confirm_box.dart` | 全局复用组件放 core/components |
| `api/class/index.ts` + `api/class/type.ts` | `lib/core/api/class/api.dart` + `model.dart` | 接口 + 模型成对放 core/api/[模块]/ |
| `pages.json` + `tabbar/` | `lib/core/router/app_router.dart` | 路由集中在 go_router |
### 目录规则总结
1. **全局公共组件** → `lib/core/components/` (ConfirmBox、VideoPlayer、AudioPlayer 这类)
2. **模块私有组件** → `lib/features/[模块]/widgets/` (只在本模块用的子组件)
3. **业务页面** → `lib/features/[模块]/` 顶层文件(my_page.dart、class_page.dart)
4. **API 接口** → `lib/core/api/[模块]/api.dart` (单例类,如 `ClassApi.instance.getUserClassPost()`)
5. **数据模型** → `lib/core/api/[模块]/model.dart` (用 `@JsonSerializable` + `json_serializable`)
6. **全局状态** → `lib/core/providers/` (Riverpod)
7. **Isar 本地数据库** → `lib/core/storage/` (模型改了要跑 `dart run build_runner build --delete-conflicting-outputs`)
---
## 二、技术栈 / API 映射表
### 状态管理
| Vue (Pinia) | Flutter (Riverpod) | 说明 |
|---|---|---|
| `store.user.name` | `ref.watch(currentUserProvider).value?.name` | ConsumerWidget + watch 响应式读取 |
| `store.setUser()` | 直接改 provider state 或 Notifier 方法 | 写操作通过 AsyncNotifier |
| `const user = computed(...)` | `final user = ref.watch(...)` | 自动依赖追踪 |
### 网络请求
| Vue | Flutter | 说明 |
|---|---|---|
| `uni.request` / `http.get` | `DioClient.instance.get('/api/xxx')` | 统一走 DioClient 单例,自动带 Token、日志、401 处理 |
| `await api.xxx()` 返回类型 | `return XxxResponse.fromJson(data)` | 接口层返回完整模型,页面层不解析 JSON |
| `catch(e)` 在页面 | 页面层统一 try/catch,不要在 api.dart 里 catch | 异常向上抛,UI 决定怎么提示 |
### 导航与弹窗
| Vue | Flutter | 说明 |
|---|---|---|
| `uni.navigateTo({url: '/pages/xxx'})` | `context.push('/xxx')` | go_router 命名路由 |
| `uni.switchTab()` | `context.go('/')` 或直接跳转 tab 路由 | 主 tab 用 StatefulShellRoute |
| `uni.showToast({title})` | 默认 SnackBar 或项目封装的 toast | 看项目现有实现,不要自己造 |
| `uni.showModal({title, content, success})` | `await showConfirmBox(context, title:, content:, confirmText:, cancelText:)` | 全局封装在 `core/components/confirm_box.dart` |
| `<up-avatar>` / `<image>` 带远程 URL | `CachedNetworkImage(imageUrl: url)` | 自动缓存,替代 Image.network |
### 本地存储
| Vue | Flutter | 说明 |
|---|---|---|
| `uni.setStorageSync('token', xxx)` | `UserStorage.setToken(xxx)` | Token 走 flutter_secure_storage(加密) |
| `uni.getStorageSync('user')` | `UserStorage.getUser()` | 统一封装在 storage 目录 |
| 业务数据缓存 | Isar 数据库(`lib/core/storage/`) | 大批量数据用 Isar,不要放 shared_preferences |
### 图片与文件
| Vue | Flutter | 说明 |
|---|---|---|
| `uni.chooseImage()` | `PickerService.pickImages()` | 统一封装,内部处理权限、压缩、Web 兜底 |
| `uni.chooseVideo()` | `PickerService.pickVideo()` | 同上 |
| OSS 上传 | `OssUploader.uploadFile()` | 已封装,内部自动签名 |
| `stringToMd5(password)` | `md5.convert(utf8.encode(password))` | crypto 包,对齐 Vue 端 |
---
## 三、布局转换核心规则
### 像素单位
UniApp 的 `rpx` → Flutter 的 `.w / .h / .sp / .r`(通过 `flutter_screenutil`,设计稿基准 375×812)。
换算:**1rpx ≈ 0.5 设计稿 px ≈ 0.5.w**。直接把 rpx 值除以 2 加 `.w` 后缀即可。
### 布局优先级
**禁止无脑 Stack + Positioned。** 按以下优先级选择:
1. **Row / Column + Expanded + Spacer**(流式布局,首选)
2. **Flex**(更灵活的流式控制)
3. **ListView / SingleChildScrollView**(列表或可滚动内容)
4. **Stack + Positioned**(仅当确实需要层叠时,如渐变背景飘出容器)
### CSS → Flutter 对照
| CSS | Flutter | 备注 |
|---|---|---|
| `display:flex; flex-direction:column` | `Column(children: [...])` | |
| `display:flex` (水平) | `Row(children: [...])` | |
| `flex:1` | `Expanded(child: ...)` | |
| `gap: 10rpx` | `SizedBox(width/height: 5.w)` | Flutter 无 gap 属性(3.16 前),用 SizedBox 补 |
| `border-radius: 50%` | `BorderRadius.circular(999)` 或 `CircleAvatar` | |
| `box-shadow` | `BoxShadow(color:, blurRadius:, offset:)` | |
| `linear-gradient` | `LinearGradient(begin:, end:, colors:, stops:)` | |
| `overflow:hidden` | `ClipRRect` 或 `Container(clipBehavior: Clip.antiAlias)` | |
| `position: absolute; top:0; left:0` | `Positioned(top:0, left:0, child:)` | Stack 内使用 |
| `z-index` | Stack 中 children 顺序(后写的在上层) | |
| `padding: 24rpx` | `Padding(padding: EdgeInsets.all(12.w))` | 数值直接除以 2 |
| `margin: 24rpx` | `Container(margin: EdgeInsets.all(12.w))` 或 Wrap padding | |
### 高频布局模式
**固定头部 + 可滚动内容:**
```dart
// Vue: scroll-view flex:1 + 头部固定
Scaffold(
body: Column(
children: [
头部(widget),
Expanded(child: SingleChildScrollView(child: 内容)),
],
),
)
```
**卡片列表:**
```dart
ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => SizedBox(height: 16.h),
itemBuilder: (_, index) => ItemCard(data: items[index]),
)
```
**渐变背景飘出(Vue HeaderBac 模式):**
```dart
Stack(
clipBehavior: Clip.none, // 允许溢出
children: [
Positioned(top:0, left:0, right:0, child: 背景图/渐变),
SafeArea(child: Column(children: [内容])), // 决定 Stack 高度
],
)
```
---
## 四、约束清单(必须遵守)
### 绝对禁止
1. **不要重复造轮子**:检查 `core/` 目录下是否已有封装(DioClient、showConfirmBox、PickerService、OssUploader、CachedNetworkImage、talker),能用就直接用。
2. **不要把私有组件放顶层**:只在一个业务模块用的组件放 `features/[模块]/widgets/`,别丢到 `core/components/`。
3. **不要裸写 Dio 或 http**:所有请求必须走 `DioClient.instance.get/post`。
4. **不要裸用 showDialog 写确认弹窗**:用 `showConfirmBox`。
5. **不要手写 JSON 解析**:API 返回用 `@JsonSerializable` + `fromJson`,不要手动 Map 转对象。
6. **不要在 build 方法里做网络请求**:请求放 provider 的 AsyncNotifier 或页面 initState。
7. **不要在 api.dart 里 catch 异常**:异常向上抛,UI 层决定怎么处理。
8. **不要新造 Toast/Loading/Empty 组件**:检查 core/components 是否已有。
### 必须遵守
1. **页面用 ConsumerWidget**(Stateless + ref.watch),有本地交互状态用 ConsumerStatefulWidget。
2. **尺寸必须加 `.w/.h/.sp/.r`**(flutter_screenutil),设计稿 375×812。
3. **加了适配后缀的 widget 不能用 `const`**(值是运行时计算的)。
4. **新图片资源必须在 pubspec.yaml 的 flutter.assets 里声明**(assets 不递归)。
5. **Isar 模型改字段后必须跑 `dart run build_runner build --delete-conflicting-outputs`**。
6. **JSON 模型改字段后必须跑 `dart run build_runner build`**(生成 *.g.dart)。
7. **路由跳转用 go_router**:`context.push('/xxx')` / `context.go('/')`,不要 Navigator.push。
8. **网络日志开关**:`DioClient.enableNetworkLog = false`(默认关,调试临时开)。
---
## 五、迁移步骤
每次迁移一个页面/组件,按这个流程走:
1. **读 Vue 源码**:先完整读完 Vue 页面的 template + script + style,理解业务逻辑。
2. **确认依赖**:
- Vue 端用了哪些 `api/` 接口?→ Flutter 端 `core/api/[模块]/api.dart` 里有没有?没有就补。
- Vue 端用了哪些全局 `components/`?→ Flutter 端 `core/components/` 有没有对应封装?没有就补。
- Vue 端用了 `pinia store` 哪些字段?→ Flutter 端 `core/providers/` 里的 Riverpod provider 有没有?
3. **搭 Flutter 骨架**:按 Vue 端的布局结构,用 Row/Column/Expanded 搭出 widget 树。
4. **填数据绑定**:Riverpod ref.watch / AsyncValue 处理 loading / error / data 三态。
5. **写交互逻辑**:onTap / async 方法,调 api.dart,更新 provider state。
6. **处理样式**:rpx → .w,CSS → Flutter 布局。
7. **处理弹窗/Toast**:用项目已有封装。
8. **自测验证**:热重载看效果,对比 Vue 端视觉。
---
## 六、高频坑清单
| 坑 | 现象 | 解决 |
|---|---|---|
| rpx 直接当数值写 | 元素偏小/偏大 | 必须除以 2 加 `.w` 后缀 |
| Stack + Positioned 导致点击区域丢失 | 按钮点不动 | Positioned 没给 width/height 时不参与 hitTest;改用流式布局 |
| SingleChildScrollView 嵌套无界 ListView | 布局报错 | 要么用 CustomScrollView + Sliver,要么 ListView 加 shrinkWrap |
| Image.network 头像列表卡顿 | 滚动掉帧 | 换 CachedNetworkImage + errorBuilder |
| assets/ 不递归 | 新图片加载失败 | pubspec.yaml 的 flutter.assets 里手动加子目录 |
| Isar 模型改字段忘记 build_runner | Schema 报错 | 改完模型立刻跑 build_runner |
| build 方法里发请求 | 每次重建都触发 | 放 initState 或 ref.listenManual |
| DioError 不存在 | 编译报错 | Dio 5.x 改名为 DioException |
| TalkerDioLogger 没关 | 控制台刷屏 | DioClient.enableNetworkLog = false |
| Stack 内 Positioned 没绑 bottom | 布局错乱 | 需要背景图飘出容器时,上层内容决定 Stack 高度,背景图 Positioned(top:0,left:0,right:0) |
| 渐变背景导致角落透明 | Stack 加 BorderRadius 后背景没跟着裁 | 用 ClipRRect 或 Container(clipBehavior: Clip.antiAlias) 包整个 Stack |
---
## 七、Vue 端需要注意的坑(迁移时保留)
Vue 端存在的容错逻辑,迁移 Flutter 时不要丢掉:
1. **网络状态判断**:Vue 端可能有 `uni.getNetworkType()` / 断网提示 → Flutter 用 `connectivity_plus` + `internet_connection_checker_plus`(项目已有 `network_status_provider.dart`)。
2. **离线缓存**:Vue 端可能有请求失败时读本地缓存的逻辑 → Flutter 用 Isar 缓存或 shared_preferences。
3. **提交前校验**:表单提交前的必填校验、网络判断 → Flutter TextEditingController + onSubmitted 时校验。
4. **平台条件编译**:`#ifdef APP-PLUS` / `#ifdef H5` → Flutter 用 `Platform.isIOS / Platform.isAndroid` 或直接不考虑(项目只做 App)。
5. **节流防抖**:Vue 端搜索、分页加载的 throttle/debounce → Flutter 用 `Timer` 或 debounce 包。
6. **分页游标**:Vue 端用 `pageIndex + prevPageId` → Flutter `PagingController<int, T>`(项目已有 `common_paged_list.dart` 封装)。
+40
View File
@@ -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();
+65
View File
@@ -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;
}
});
}
}
+9 -4
View File
@@ -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;
}
+68
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/components/confirm_box.dart';
/// ============================================================
/// 【RegisterPage】注册页 —— 对照 Vue 版 pages/login/register.vue
///
/// 一、表单字段(3 个,带校验):
/// 账号 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/
/// 密码 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/
/// 确认密码 必须与密码一致
///
/// 二、注册流程:
/// isFormValid → 协议勾选 → 调 UserApi.register({account, password})
/// → 成功 Toast "注册成功" → 1 秒后返回登录页
///
/// 三、与 Vue 的对应关系:
/// validateField / validateAll → Flutter 版字段级校验(_validateXxx)
/// isFormValid computed → Flutter 版 _isFormValid getter
/// debounce 防重复提交 → Flutter 用 _loading 标记防连点
/// 密码 MD5 → UserApi.register 内部已处理
///
/// 四、UI 风格:
/// 输入框对齐登录页(灰底 #F9F9F9 + 圆角 24.r),顶部 AppBar 标题"注册账号"。
/// ============================================================
/// 账号/密码校验正则:6-16 位字母或数字(对齐 Vue register.vue 的 validateField)
final _kAccountRegex = RegExp(r'^[a-zA-Z0-9]{6,16}$');
class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key});
@override
ConsumerState<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
// ---------- 表单控制器 ----------
final TextEditingController _accountController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _hasAgreed = false;
bool _loading = false;
// ---------- 错误信息(字段级,与 Vue errors 对齐) ----------
String _accountError = '';
String _passwordError = '';
String _confirmPasswordError = '';
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
// ==================== 校验(对齐 Vue validateField) ====================
String _validateAccount() {
final v = _accountController.text.trim();
if (v.isEmpty) return '请填写账号';
if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字';
return '';
}
String _validatePassword() {
final v = _passwordController.text.trim();
if (v.isEmpty) return '请填写密码';
if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字';
// 密码变化时重校验确认密码
if (_confirmPasswordController.text.isNotEmpty) {
_confirmPasswordError = _confirmPasswordController.text == v
? ''
: '两次输入的密码不一致';
}
return '';
}
String _validateConfirmPassword() {
final v = _confirmPasswordController.text;
if (v.isEmpty) return '请再次输入密码';
if (v != _passwordController.text) return '两次输入的密码不一致';
return '';
}
/// 表单是否完整有效(对齐 Vue isFormValid computed)
bool get _isFormValid {
return _kAccountRegex.hasMatch(_accountController.text.trim()) &&
_kAccountRegex.hasMatch(_passwordController.text.trim()) &&
_confirmPasswordController.text.isNotEmpty &&
_confirmPasswordController.text == _passwordController.text;
}
// ==================== 注册逻辑 ====================
Future<void> _handleRegister() async {
if (_loading) return;
// 全量校验
_accountError = _validateAccount();
_passwordError = _validatePassword();
_confirmPasswordError = _validateConfirmPassword();
setState(() {});
if (!_isFormValid) return;
// 未勾选协议 → 弹确认框(对齐 Vue register.vue 的协议弹窗)
if (!_hasAgreed) {
final agreed = await showConfirmBox(
context,
title: '用户协议及隐私条款',
content:
'感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。'
'为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读'
'《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!',
cancelText: '不同意',
confirmText: '同意并继续',
);
if (!agreed || !mounted) return;
setState(() => _hasAgreed = true);
}
setState(() => _loading = true);
try {
await UserApi.register(
account: _accountController.text.trim(),
password: _passwordController.text,
);
if (!mounted) return;
_toast('注册成功');
// 1 秒后回到登录页(context.go 替换当前栈,最稳不依赖 canPop)
Future<void>.delayed(const Duration(seconds: 1), () {
if (mounted) context.go('/login');
});
} on ApiException catch (e) {
if (mounted) _toast(e.message);
} catch (e) {
if (mounted) _toast('注册失败,请稍后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _toast(String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
// ==================== 导航 ====================
void _goToAgreement() => context.push('/user-agreement');
void _goToPrivacy() => context.push('/privacy-policy');
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('注册账号', style: TextStyle(color: Color(0xFF323233))),
iconTheme: const IconThemeData(color: Color(0xFF323233)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/login'),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 25.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 20.h),
// 账号
_buildField(
label: '账号',
controller: _accountController,
hintText: '请输入您的账号',
errorText: _accountError,
onChanged: (_) =>
setState(() => _accountError = _validateAccount()),
),
SizedBox(height: 12.h),
// 密码
_buildField(
label: '密码',
controller: _passwordController,
hintText: '请输入您的密码',
obscureText: _obscurePassword,
errorText: _passwordError,
onChanged: (_) => setState(() {
_passwordError = _validatePassword();
}),
onToggleObscure: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
SizedBox(height: 12.h),
// 确认密码
_buildField(
label: '确认密码',
controller: _confirmPasswordController,
hintText: '请再次输入密码',
obscureText: _obscureConfirmPassword,
errorText: _confirmPasswordError,
onChanged: (_) => setState(
() => _confirmPasswordError = _validateConfirmPassword(),
),
onToggleObscure: () => setState(
() => _obscureConfirmPassword = !_obscureConfirmPassword,
),
),
SizedBox(height: 25.h),
// 协议勾选
_buildAgreementRow(),
SizedBox(height: 20.h),
// 注册按钮
_buildRegisterButton(),
SizedBox(height: 20.h),
// 已有账号,去登录
_buildFooterLink(),
],
),
),
),
);
}
/// 通用输入字段(带 label + 灰底圆角 + 错误提示)
Widget _buildField({
required String label,
required TextEditingController controller,
required String hintText,
required String errorText,
bool obscureText = false,
ValueChanged<String>? onChanged,
VoidCallback? onToggleObscure,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 输入框容器(灰底 #F9F9F9 + 圆角 24.r,对齐登录页)
Container(
height: 48.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: const Color(0xFFF9F9F9),
borderRadius: BorderRadius.circular(24.r),
),
child: Row(
children: [
// label 文字(Vue register-page__label 140rpx 宽)
SizedBox(
width: 70.w, // 140rpx → .w
child: Text(
label,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF323233),
),
),
),
Expanded(
child: TextField(
controller: controller,
obscureText: obscureText,
onChanged: onChanged,
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF323233),
),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: hintText,
hintStyle: TextStyle(
fontSize: 14.sp,
color: const Color(0xFFC0C4CC),
),
),
),
),
if (onToggleObscure != null)
GestureDetector(
onTap: onToggleObscure,
child: Icon(
obscureText
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: const Color(0xFFC0C4CC),
size: 24,
),
),
],
),
),
// 错误提示:固定高度占位,有错误时显示文字,无错误时空白 → 输入框位置不动
SizedBox(
height: 22.h,
child: errorText.isNotEmpty
? Padding(
padding: EdgeInsets.only(left: 16.w, top: 6.h),
child: Text(
errorText,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFEE0A24),
),
),
)
: null,
),
],
);
}
Widget _buildAgreementRow() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InkWell(
onTap: () => setState(() => _hasAgreed = !_hasAgreed),
child: Icon(
_hasAgreed
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
size: 16.w,
color: _hasAgreed
? const Color(0xFF3D7DF5)
: const Color(0xFFC0C4CC),
),
),
SizedBox(width: 6.w),
Expanded(
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)),
children: [
const TextSpan(text: '我已阅读并同意'),
TextSpan(
text: '《用户协议》',
style: const TextStyle(color: Color(0xFF3D7DF5)),
recognizer: TapGestureRecognizer()..onTap = _goToAgreement,
),
const TextSpan(text: '和'),
TextSpan(
text: '《隐私政策》',
style: const TextStyle(color: Color(0xFF3D7DF5)),
recognizer: TapGestureRecognizer()..onTap = _goToPrivacy,
),
],
),
),
),
],
);
}
Widget _buildRegisterButton() {
return SizedBox(
width: double.infinity,
height: 48.h,
child: FilledButton(
onPressed: _loading ? null : _handleRegister,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3D7DF5),
disabledBackgroundColor: _isFormValid
? const Color(0xFF3D7DF5).withValues(alpha: 0.6)
: const Color(0xFFC0C4CC), // 表单无效时整体灰色,对齐 Vue btn--disabled
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24.r),
),
),
child: _loading
? SizedBox(
width: 18.w,
height: 18.w,
child: const CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
)
: Text(
'注册',
style: TextStyle(fontSize: 18.sp, color: Colors.white),
),
),
);
}
Widget _buildFooterLink() {
return Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () => context.canPop() ? context.pop() : context.go('/login'),
child: Text(
'已有账号,去登录',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)),
),
),
);
}
}