Compare commits
4
Commits
7f5d160212
...
955d6455d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
955d6455d8 | ||
|
|
f718749ad7 | ||
|
|
9017ec669f | ||
|
|
151ae5afb9 |
@@ -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` 封装)。
|
||||
@@ -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 {
|
||||
|
||||
+537
-260
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user