12 KiB
12 KiB
name, description
| name | description |
|---|---|
| vue3-to-flutter | 把 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 |
目录规则总结
- 全局公共组件 →
lib/core/components/(ConfirmBox、VideoPlayer、AudioPlayer 这类) - 模块私有组件 →
lib/features/[模块]/widgets/(只在本模块用的子组件) - 业务页面 →
lib/features/[模块]/顶层文件(my_page.dart、class_page.dart) - API 接口 →
lib/core/api/[模块]/api.dart(单例类,如ClassApi.instance.getUserClassPost()) - 数据模型 →
lib/core/api/[模块]/model.dart(用@JsonSerializable+json_serializable) - 全局状态 →
lib/core/providers/(Riverpod) - 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。 按以下优先级选择:
- Row / Column + Expanded + Spacer(流式布局,首选)
- Flex(更灵活的流式控制)
- ListView / SingleChildScrollView(列表或可滚动内容)
- 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 |
高频布局模式
固定头部 + 可滚动内容:
// Vue: scroll-view flex:1 + 头部固定
Scaffold(
body: Column(
children: [
头部(widget),
Expanded(child: SingleChildScrollView(child: 内容)),
],
),
)
卡片列表:
ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => SizedBox(height: 16.h),
itemBuilder: (_, index) => ItemCard(data: items[index]),
)
渐变背景飘出(Vue HeaderBac 模式):
Stack(
clipBehavior: Clip.none, // 允许溢出
children: [
Positioned(top:0, left:0, right:0, child: 背景图/渐变),
SafeArea(child: Column(children: [内容])), // 决定 Stack 高度
],
)
四、约束清单(必须遵守)
绝对禁止
- 不要重复造轮子:检查
core/目录下是否已有封装(DioClient、showConfirmBox、PickerService、OssUploader、CachedNetworkImage、talker),能用就直接用。 - 不要把私有组件放顶层:只在一个业务模块用的组件放
features/[模块]/widgets/,别丢到core/components/。 - 不要裸写 Dio 或 http:所有请求必须走
DioClient.instance.get/post。 - 不要裸用 showDialog 写确认弹窗:用
showConfirmBox。 - 不要手写 JSON 解析:API 返回用
@JsonSerializable+fromJson,不要手动 Map 转对象。 - 不要在 build 方法里做网络请求:请求放 provider 的 AsyncNotifier 或页面 initState。
- 不要在 api.dart 里 catch 异常:异常向上抛,UI 层决定怎么处理。
- 不要新造 Toast/Loading/Empty 组件:检查 core/components 是否已有。
必须遵守
- 页面用 ConsumerWidget(Stateless + ref.watch),有本地交互状态用 ConsumerStatefulWidget。
- 尺寸必须加
.w/.h/.sp/.r(flutter_screenutil),设计稿 375×812。 - 加了适配后缀的 widget 不能用
const(值是运行时计算的)。 - 新图片资源必须在 pubspec.yaml 的 flutter.assets 里声明(assets 不递归)。
- Isar 模型改字段后必须跑
dart run build_runner build --delete-conflicting-outputs。 - JSON 模型改字段后必须跑
dart run build_runner build(生成 *.g.dart)。 - 路由跳转用 go_router:
context.push('/xxx')/context.go('/'),不要 Navigator.push。 - 网络日志开关:
DioClient.enableNetworkLog = false(默认关,调试临时开)。
五、迁移步骤
每次迁移一个页面/组件,按这个流程走:
- 读 Vue 源码:先完整读完 Vue 页面的 template + script + style,理解业务逻辑。
- 确认依赖:
- Vue 端用了哪些
api/接口?→ Flutter 端core/api/[模块]/api.dart里有没有?没有就补。 - Vue 端用了哪些全局
components/?→ Flutter 端core/components/有没有对应封装?没有就补。 - Vue 端用了
pinia store哪些字段?→ Flutter 端core/providers/里的 Riverpod provider 有没有?
- Vue 端用了哪些
- 搭 Flutter 骨架:按 Vue 端的布局结构,用 Row/Column/Expanded 搭出 widget 树。
- 填数据绑定:Riverpod ref.watch / AsyncValue 处理 loading / error / data 三态。
- 写交互逻辑:onTap / async 方法,调 api.dart,更新 provider state。
- 处理样式:rpx → .w,CSS → Flutter 布局。
- 处理弹窗/Toast:用项目已有封装。
- 自测验证:热重载看效果,对比 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 时不要丢掉:
- 网络状态判断:Vue 端可能有
uni.getNetworkType()/ 断网提示 → Flutter 用connectivity_plus+internet_connection_checker_plus(项目已有network_status_provider.dart)。 - 离线缓存:Vue 端可能有请求失败时读本地缓存的逻辑 → Flutter 用 Isar 缓存或 shared_preferences。
- 提交前校验:表单提交前的必填校验、网络判断 → Flutter TextEditingController + onSubmitted 时校验。
- 平台条件编译:
#ifdef APP-PLUS/#ifdef H5→ Flutter 用Platform.isIOS / Platform.isAndroid或直接不考虑(项目只做 App)。 - 节流防抖:Vue 端搜索、分页加载的 throttle/debounce → Flutter 用
Timer或 debounce 包。 - 分页游标:Vue 端用
pageIndex + prevPageId→ FlutterPagingController<int, T>(项目已有common_paged_list.dart封装)。