235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
---
|
||
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` 封装)。
|