合并代码
@@ -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` 封装)。
|
||||||
|
After Width: | Height: | Size: 901 B |
|
After Width: | Height: | Size: 519 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 655 B |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 328 B |
|
After Width: | Height: | Size: 492 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 727 B |
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:new_learn/core/api/my_report/model.dart';
|
||||||
|
import 'package:new_learn/core/network/dio_client.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【my_report 模块接口】我的报告 —— 对标 Vue3 端 api/myReport/index.ts
|
||||||
|
///
|
||||||
|
/// getReportPage —— 报告列表分页(页码分页)
|
||||||
|
/// GET /api/v1/UserReport/GetUserReportList
|
||||||
|
/// 参数:pageIndex / pageSize / reportType(可选)
|
||||||
|
/// ============================================================
|
||||||
|
class UserReportApi {
|
||||||
|
UserReportApi._();
|
||||||
|
|
||||||
|
static final UserReportApi instance = UserReportApi._();
|
||||||
|
|
||||||
|
/// 报告列表分页(页码分页:pageIndex / pageSize)
|
||||||
|
/// [reportType] 可选筛选条件,Vue 端默认不传
|
||||||
|
Future<UserReportPage> getReportPage({
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 10,
|
||||||
|
int? reportType,
|
||||||
|
}) async {
|
||||||
|
final query = <String, dynamic>{
|
||||||
|
'pageIndex': pageIndex,
|
||||||
|
'pageSize': pageSize,
|
||||||
|
};
|
||||||
|
if (reportType != null) query['reportType'] = reportType;
|
||||||
|
|
||||||
|
final data = await DioClient.instance.get(
|
||||||
|
'/api/v1/UserReport/GetUserReportList',
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
|
return UserReportPage.fromJson(data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/// ============================================================
|
||||||
|
/// 【my_report 模块模型】我的报告 —— 对标 Vue3 端 api/myReport/type.ts
|
||||||
|
///
|
||||||
|
/// UserReportDto(单条报告)/ UserReportPage(分页响应)
|
||||||
|
///
|
||||||
|
/// 字段说明:
|
||||||
|
/// id 报告 ID(后端大整数,返回 string 类型,前端也用 String 存)
|
||||||
|
/// reportName 报告名称(如"数学分析报告")
|
||||||
|
/// reportType 报告类型
|
||||||
|
/// createTime 创建时间字符串(如 "2026-08-28 15:24:39" 或 ISO "T" 分隔)
|
||||||
|
/// reportUrl 报告详情 H5 页面 URL(点击列表项后 WebView 加载这个)
|
||||||
|
///
|
||||||
|
/// 序列化:手动 fromJson,做了类型兼容(后端 id 有时 string 有时 number,
|
||||||
|
/// 统一转 String;reportUrl 清理首尾空白和反引号)。
|
||||||
|
/// 与 my_point/model.dart 保持一致,不依赖 build_runner。
|
||||||
|
/// ============================================================
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// 辅助:id 字段兼容 string / num 两种后端返回,统一转 String
|
||||||
|
String _asStringId(dynamic value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value is String) return value.trim();
|
||||||
|
if (value is num) return value.toInt().toString();
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 辅助:清理 reportUrl 首尾可能的反引号 / 空白(后端可能包了一层引号)
|
||||||
|
String _cleanUrl(String raw) {
|
||||||
|
final cleaned = raw.trim();
|
||||||
|
// 去掉首尾反引号(用户粘贴时可能加的,或后端真返回了)
|
||||||
|
if (cleaned.startsWith('`') && cleaned.endsWith('`')) {
|
||||||
|
return cleaned.substring(1, cleaned.length - 1).trim();
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条报告
|
||||||
|
class UserReportDto {
|
||||||
|
const UserReportDto({
|
||||||
|
this.id = '',
|
||||||
|
this.reportName = '',
|
||||||
|
this.reportType = 0,
|
||||||
|
this.createTime = '',
|
||||||
|
this.reportUrl = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // 后端大整数 ID,用 String 存避免精度丢失
|
||||||
|
final String reportName;
|
||||||
|
final int reportType;
|
||||||
|
final String createTime;
|
||||||
|
final String reportUrl;
|
||||||
|
|
||||||
|
factory UserReportDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserReportDto(
|
||||||
|
id: _asStringId(json['id']),
|
||||||
|
reportName: (json['reportName'] as String?) ?? '',
|
||||||
|
reportType: (json['reportType'] as num?)?.toInt() ?? 0,
|
||||||
|
createTime: (json['createTime'] as String?) ?? '',
|
||||||
|
reportUrl: _cleanUrl((json['reportUrl'] as String?) ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 报告分页响应
|
||||||
|
class UserReportPage {
|
||||||
|
const UserReportPage({
|
||||||
|
this.data = const [],
|
||||||
|
this.total = 0,
|
||||||
|
this.pageIndex = 1,
|
||||||
|
this.pageSize = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<UserReportDto> data;
|
||||||
|
final int total;
|
||||||
|
final int pageIndex;
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||||
|
factory UserReportPage.fromJson(Map<String, dynamic> json) {
|
||||||
|
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||||
|
return UserReportPage(
|
||||||
|
data: (payload['data'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(UserReportDto.fromJson)
|
||||||
|
.toList(),
|
||||||
|
total: (payload['total'] as num?)?.toInt() ?? 0,
|
||||||
|
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
|
||||||
|
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:new_learn/core/utils/img_url.dart';
|
||||||
|
import 'package:new_learn/core/utils/screen_adapt_template.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【ResolveProblemBox】攻克任务卡(对标 Vue ResolveProblem.vue)
|
||||||
|
///
|
||||||
|
/// 用于展示"刚刚攻克了 「xxx」 的所有高难考点"类提示。
|
||||||
|
/// 由 class_update_widgets.dart 抽离,详情页也可复用。
|
||||||
|
/// 背景图 + 云朵装饰走 CDN,与 Vue 版 $img('/StaticResource/...') 同源。
|
||||||
|
/// ============================================================
|
||||||
|
class ResolveProblemBox extends StatelessWidget {
|
||||||
|
const ResolveProblemBox({
|
||||||
|
super.key,
|
||||||
|
this.title = '刚刚攻克了',
|
||||||
|
required this.desc,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 标题(默认 "刚刚攻克了")
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// 攻克的知识点描述(如 "二次函数最值问题")
|
||||||
|
final String desc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||||||
|
|
||||||
|
final bgUrl = ImgUrl.build(
|
||||||
|
isPad
|
||||||
|
? '/StaticResource/2026-06-02/resolve-bg2-pad1780388446390.png'
|
||||||
|
: '/StaticResource/2026-05-07/resolve-bg2.png',
|
||||||
|
);
|
||||||
|
final cloudUrl = ImgUrl.build(
|
||||||
|
isPad
|
||||||
|
? '/StaticResource/2026-06-02/cloud-pad1780388443051.png'
|
||||||
|
: '/StaticResource/2026-05-07/cloud.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
final cloudHeight = isPad ? 107.h : 74.h;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10.r),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: CachedNetworkImageProvider(bgUrl),
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 裁剪所有子元素到圆角内(对标 Vue overflow:hidden)
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
// Stack 默认不裁剪 Positioned 子元素,必须显式设置
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
// 底部云朵装饰(贴底部,z-index 1)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: cloudUrl,
|
||||||
|
height: cloudHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 文本层(padding 在云朵之上,z-index 2)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(19.w, 25.h, 19.w, 11.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '「$desc」',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(
|
||||||
|
text: ' 的所有高难考点!',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF45597E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:new_learn/core/api/my_report/api.dart';
|
||||||
|
import 'package:new_learn/core/api/my_report/model.dart';
|
||||||
|
import 'package:new_learn/core/components/common_paged_list.dart';
|
||||||
|
import 'package:new_learn/core/logger/app_talker.dart';
|
||||||
|
import 'package:new_learn/core/utils/img_url.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
|
||||||
|
///
|
||||||
|
/// 一、数据来源:
|
||||||
|
/// 报告分页 → UserReportApi.instance.getReportPage → CommonPagedList
|
||||||
|
///
|
||||||
|
/// 二、UI 结构:
|
||||||
|
/// Scaffold 白色 AppBar + 背景 #F5F8FA 的列表区域
|
||||||
|
/// 卡片背景图 + 左对齐 icon/标签 + 中间文字 + 右侧箭头
|
||||||
|
///
|
||||||
|
/// 三、点击卡片 → 跳 /report-detail(WebView 详情页)
|
||||||
|
/// reportUrl 为空 → SnackBar "报告暂未生成"
|
||||||
|
///
|
||||||
|
/// 四、先只做 phone 布局,Pad 后续再说
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 学科 icon 映射表(对标 Vue utils/constant.js SUBJECT_ICON_MAP)
|
||||||
|
const Map<String, String> _subjectIconMap = {
|
||||||
|
'语文': '/StaticResource/2026-06-11/shuxue1781163770612.png',
|
||||||
|
'化学': '/StaticResource/2026-06-11/huaxue1781163828443.png',
|
||||||
|
'英语': '/StaticResource/2026-06-11/yingyu1781163725441.png',
|
||||||
|
'物理': '/StaticResource/2026-06-11/wuli1781148438346.png',
|
||||||
|
'数学': '/StaticResource/2026-06-11/shuxue1781163770612.png',
|
||||||
|
'生物': '/StaticResource/2026-06-11/shengwu1781163791768.png',
|
||||||
|
'历史': '/StaticResource/2026-06-11/lishi1781163811822.png',
|
||||||
|
'政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png',
|
||||||
|
'地理': '/StaticResource/2026-06-11/dili1781163844311.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
|
||||||
|
String _extractSubjectName(UserReportDto item) {
|
||||||
|
final name = item.reportName.trim();
|
||||||
|
if (name.length < 2) return name;
|
||||||
|
return name.substring(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取学科 icon URL(无匹配则用数学兜底,对标 Vue getSubjectIcon)
|
||||||
|
String _getSubjectIconUrl(String subjectName) {
|
||||||
|
final path = _subjectIconMap[subjectName] ?? _subjectIconMap['数学']!;
|
||||||
|
return ImgUrl.build(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化创建时间(对标 Vue getReportTime)
|
||||||
|
String _formatCreateTime(String raw) {
|
||||||
|
if (raw.isEmpty) return '';
|
||||||
|
final normalized = raw.replaceAll('T', ' ');
|
||||||
|
return normalized.length > 16 ? normalized.substring(0, 16) : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyReportPage extends ConsumerStatefulWidget {
|
||||||
|
const MyReportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MyReportPage> createState() => _MyReportPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyReportPageState extends ConsumerState<MyReportPage> {
|
||||||
|
static const int _pageSize = 10;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
talker.debug('打开页面:我的报告');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页加载回调
|
||||||
|
Future<PagingResult<UserReportDto>> _loadPage(int pageIndex) async {
|
||||||
|
final page = await UserReportApi.instance.getReportPage(
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
pageSize: _pageSize,
|
||||||
|
);
|
||||||
|
final hasNext = page.data.length >= _pageSize;
|
||||||
|
return PagingResult(data: page.data, hasNext: hasNext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击卡片:跳 WebView 详情页
|
||||||
|
void _onTapCard(UserReportDto item) {
|
||||||
|
if (item.reportUrl.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('报告暂未生成')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.push(
|
||||||
|
'/report-detail',
|
||||||
|
extra: {
|
||||||
|
'title': item.reportName.isEmpty ? '我的报告' : item.reportName,
|
||||||
|
'url': item.reportUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('我的报告', style: TextStyle(color: Color(0xFF1D2B47))),
|
||||||
|
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Container(
|
||||||
|
color: const Color(0xFFF5F8FA),
|
||||||
|
child: CommonPagedList<UserReportDto>(
|
||||||
|
load: _loadPage,
|
||||||
|
padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 0),
|
||||||
|
separatorBuilder: (_, _) => SizedBox(height: 14.h),
|
||||||
|
buildItem: (context, item, index) {
|
||||||
|
return _ReportCard(item: item, onTap: () => _onTapCard(item));
|
||||||
|
},
|
||||||
|
emptyWidget: Transform.translate(
|
||||||
|
offset: Offset(0, -80.h),
|
||||||
|
child: const Center(
|
||||||
|
child: Text('暂无内容', style: TextStyle(color: Color(0xFF687FAD))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【_ReportCard】报告卡片
|
||||||
|
///
|
||||||
|
/// 布局(Row 从左到右):
|
||||||
|
/// [icon + 学科标签] → [报告名 + 时间] → [箭头]
|
||||||
|
///
|
||||||
|
/// 背景图叠底,InkWell 提供点击水波纹。
|
||||||
|
/// 图标/标签自然靠左,不做偏移 hack。
|
||||||
|
/// ============================================================
|
||||||
|
class _ReportCard extends StatelessWidget {
|
||||||
|
const _ReportCard({required this.item, required this.onTap});
|
||||||
|
|
||||||
|
final UserReportDto item;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final subjectName = _extractSubjectName(item);
|
||||||
|
final subjectIconUrl = _getSubjectIconUrl(subjectName);
|
||||||
|
final timeText = _formatCreateTime(item.createTime);
|
||||||
|
|
||||||
|
const bgPath =
|
||||||
|
'/StaticResource/2026-06-10/examListItem_bg1781074465963.png';
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
child: Container(
|
||||||
|
height: 109.h,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// 底层:卡片背景图
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: ImgUrl.build(bgPath),
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
placeholder: (_, _) => Container(color: Colors.white),
|
||||||
|
errorWidget: (_, _, _) => Container(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 上层内容:Row 左对齐,padding 控制整体边距
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 22.w),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// ── 左侧:学科 icon + 标签(垂直排列,自然靠左) ──
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: subjectIconUrl,
|
||||||
|
width: 56.w,
|
||||||
|
height: 56.w,
|
||||||
|
placeholder: (_, _) => const SizedBox.shrink(),
|
||||||
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||||
|
height: 25.h,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF5FE),
|
||||||
|
borderRadius: BorderRadius.circular(7.r),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
subjectName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF3D7DF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(width: 40.w),
|
||||||
|
// ── 中间:报告名 + 时间(Expanded 占剩余) ──
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Text(
|
||||||
|
item.reportName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 16.sp,
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
Text(
|
||||||
|
timeText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
color: const Color(0xFF687FAD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── 右侧:箭头 ──
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 10.h),
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: const Color(0xFF4B6B99),
|
||||||
|
size: 22.sp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:new_learn/core/logger/app_talker.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【ReportDetailPage】报告详情 WebView 页 —— 对标 Vue reportWebView.vue
|
||||||
|
///
|
||||||
|
/// 一、为什么用 flutter_inappwebview 而不是官方 webview_flutter:
|
||||||
|
/// ✅ 需要 JS → Flutter 回调(addJavaScriptHandler)
|
||||||
|
/// ✅ 需要 Flutter → JS 主动调用(callAsyncJavaScript / evaluateJavascript)
|
||||||
|
/// ✅ 比 webview_flutter 功能更全,后续扩展方便
|
||||||
|
///
|
||||||
|
/// 二、路由参数(通过 GoRouteState.extra 传 Map):
|
||||||
|
/// context.push('/report-detail', extra: {'title': '...', 'url': '...'})
|
||||||
|
/// - title:AppBar 标题
|
||||||
|
/// - url:WebView 加载的 H5 报告 URL
|
||||||
|
///
|
||||||
|
/// 三、JS Bridge 完整指南(H5 ↔ Flutter 双向通信):
|
||||||
|
///
|
||||||
|
/// ┌─────────────── H5 调 Flutter ───────────────┐
|
||||||
|
/// │ │
|
||||||
|
/// │ Flutter 注册 handler(见 _registerJsHandlers)│
|
||||||
|
/// │ controller.addJavaScriptHandler( │
|
||||||
|
/// │ handlerName: 'closePage', │
|
||||||
|
/// │ callback: (args) { ... } │
|
||||||
|
/// │ ); │
|
||||||
|
/// │ │
|
||||||
|
/// │ H5 侧调用: │
|
||||||
|
/// │ // 关闭当前 WebView 页 │
|
||||||
|
/// │ window.flutter_inappwebview │
|
||||||
|
/// │ .callHandler('closePage'); │
|
||||||
|
/// │ │
|
||||||
|
/// │ // 带参数调用: │
|
||||||
|
/// │ window.flutter_inappwebview │
|
||||||
|
/// │ .callHandler('onReportAction', { │
|
||||||
|
/// │ action: 'download', │
|
||||||
|
/// │ data: 'xxx' │
|
||||||
|
/// │ }); │
|
||||||
|
/// │ │
|
||||||
|
/// │ H5 兼容写法(推荐,同时支持 UniApp web-view): │
|
||||||
|
/// │ function callFlutter(handlerName, args) { │
|
||||||
|
/// │ // Flutter inappwebview │
|
||||||
|
/// │ if (window.flutter_inappwebview) { │
|
||||||
|
/// │ window.flutter_inappwebview │
|
||||||
|
/// │ .callHandler(handlerName, args); │
|
||||||
|
/// │ } │
|
||||||
|
/// │ // UniApp web-view(可选,按需扩展) │
|
||||||
|
/// │ // #ifdef APP-PLUS │
|
||||||
|
/// │ // plus.webview.currentWebview()... │
|
||||||
|
/// │ // #endif │
|
||||||
|
/// │ } │
|
||||||
|
/// │ callFlutter('closePage'); │
|
||||||
|
/// │ │
|
||||||
|
/// └───────────────────────────────────────────────┘
|
||||||
|
///
|
||||||
|
/// ┌─────────────── Flutter 调 H5 ───────────────┐
|
||||||
|
/// │ │
|
||||||
|
/// │ // 主动调 H5 全局函数: │
|
||||||
|
/// │ await controller.callAsyncJavaScript( │
|
||||||
|
/// │ functionBody: │
|
||||||
|
/// │ 'window.onFlutterMessage("hello");', │
|
||||||
|
/// │ ); │
|
||||||
|
/// │ │
|
||||||
|
/// │ // 或者执行任意 JS 代码: │
|
||||||
|
/// │ await controller.evaluateJavascript( │
|
||||||
|
/// │ source: 'document.title', │
|
||||||
|
/// │ ); │
|
||||||
|
/// │ │
|
||||||
|
/// └───────────────────────────────────────────────┘
|
||||||
|
///
|
||||||
|
/// 四、支持的 JS handler 列表(H5 可调用这些名字):
|
||||||
|
///
|
||||||
|
/// handlerName 参数 作用
|
||||||
|
/// ──────────────────────────────────────────────────
|
||||||
|
/// closePage 无 关闭当前 WebView 页
|
||||||
|
/// onReportAction {action,data} H5 主动通知 Flutter 做某事
|
||||||
|
/// action 可取:
|
||||||
|
/// 'share' → 触发分享(TODO: 接 share_plus)
|
||||||
|
/// 'download' → 下载/保存报告
|
||||||
|
/// 'refresh' → Flutter 刷列表后回退
|
||||||
|
///
|
||||||
|
/// 五、平台配置(如需):
|
||||||
|
/// Android:AndroidManifest.xml 需 INTERNET 权限(flutter 默认已有)
|
||||||
|
/// iOS:Info.plist 需 NSAppTransportSecurity 允许 HTTP(如果报告页是 HTTPS 可忽略)
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
class ReportDetailPage extends StatefulWidget {
|
||||||
|
const ReportDetailPage({super.key, required this.title, required this.url});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReportDetailPage> createState() => _ReportDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportDetailPageState extends State<ReportDetailPage> {
|
||||||
|
InAppWebViewController? _webController;
|
||||||
|
|
||||||
|
/// WebView 加载进度(0~1),用于底部蓝色进度条
|
||||||
|
double _loadingProgress = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
talker.debug('打开报告详情:${widget.title} → ${widget.url}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(color: Color(0xFF1D2B47)),
|
||||||
|
),
|
||||||
|
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
// 先尝试 WebView 内部 history 回退,不行再 pop 页面
|
||||||
|
onPressed: () async {
|
||||||
|
final controller = _webController;
|
||||||
|
if (controller != null && await controller.canGoBack()) {
|
||||||
|
await controller.goBack();
|
||||||
|
} else if (context.mounted) {
|
||||||
|
context.canPop() ? context.pop() : context.go('/my');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 底部加载进度条(可选,提升体验)
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(2),
|
||||||
|
child: _loadingProgress < 1.0
|
||||||
|
? LinearProgressIndicator(
|
||||||
|
value: _loadingProgress,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: InAppWebView(
|
||||||
|
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
||||||
|
initialSettings: InAppWebViewSettings(
|
||||||
|
// 允许 JS(报告页大概率需要)
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
// 支持内联视频播放
|
||||||
|
allowsInlineMediaPlayback: true,
|
||||||
|
// 允许文件访问
|
||||||
|
allowFileAccess: true,
|
||||||
|
// 自适应内容(防止移动端 H5 在 WebView 里显示过宽)
|
||||||
|
useHybridComposition: true,
|
||||||
|
),
|
||||||
|
onWebViewCreated: (controller) {
|
||||||
|
_webController = controller;
|
||||||
|
// ✅【关键】注册 JS Bridge:H5 通过 window.flutter_inappwebview.callHandler 调这些
|
||||||
|
_registerJsHandlers(controller);
|
||||||
|
talker.debug('WebView created, JS handlers registered');
|
||||||
|
},
|
||||||
|
onLoadStart: (controller, url) {
|
||||||
|
setState(() => _loadingProgress = 0);
|
||||||
|
talker.debug('WebView loading start: $url');
|
||||||
|
},
|
||||||
|
onLoadStop: (controller, url) async {
|
||||||
|
setState(() => _loadingProgress = 1);
|
||||||
|
talker.debug('WebView loading stop: $url');
|
||||||
|
// TODO: 可选:页面加载完后主动调 H5 的初始化函数
|
||||||
|
// await controller.callAsyncJavaScript(
|
||||||
|
// functionBody: 'window.onFlutterReady?.();',
|
||||||
|
// );
|
||||||
|
},
|
||||||
|
onProgressChanged: (controller, progress) {
|
||||||
|
setState(() => _loadingProgress = progress / 100.0);
|
||||||
|
},
|
||||||
|
// WebView 内部请求拦截(可选:如需给 H5 接口加 token,可在这里处理)
|
||||||
|
// shouldInterceptRequest: (controller, request) async {
|
||||||
|
// final url = request.url?.toString() ?? '';
|
||||||
|
// if (url.contains('/api/')) {
|
||||||
|
// // 给 H5 的接口请求追加 Authorization header
|
||||||
|
// return request.copyWith(
|
||||||
|
// headers: {
|
||||||
|
// ...request.headers ?? {},
|
||||||
|
// 'Authorization': 'Bearer ${DioClient.instance.accessToken}',
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// return null;
|
||||||
|
// },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// JS Bridge 注册 —— 所有 handler 在这里统一定义
|
||||||
|
//
|
||||||
|
// H5 调用方式:window.flutter_inappwebview.callHandler('closePage')
|
||||||
|
// 或带参数:window.flutter_inappwebview.callHandler('onReportAction', {action:'xxx'})
|
||||||
|
//
|
||||||
|
// 注意:handler 必须在 onWebViewCreated 里注册,页面加载前就要就绪。
|
||||||
|
// ============================================================
|
||||||
|
void _registerJsHandlers(InAppWebViewController controller) {
|
||||||
|
// ---- 1. closePage:关闭当前 WebView 页 ----
|
||||||
|
// H5 调用:window.flutter_inappwebview.callHandler('closePage')
|
||||||
|
controller.addJavaScriptHandler(
|
||||||
|
handlerName: 'closePage',
|
||||||
|
callback: (args) {
|
||||||
|
talker.debug('[JS→Flutter] closePage called, args: $args');
|
||||||
|
if (context.mounted) {
|
||||||
|
context.canPop() ? context.pop() : context.go('/my');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 2. onReportAction:H5 通知 Flutter 执行某动作 ----
|
||||||
|
// H5 调用:
|
||||||
|
// window.flutter_inappwebview.callHandler('onReportAction', {
|
||||||
|
// action: 'download',
|
||||||
|
// data: 'report_xxx.pdf'
|
||||||
|
// })
|
||||||
|
controller.addJavaScriptHandler(
|
||||||
|
handlerName: 'onReportAction',
|
||||||
|
callback: (args) {
|
||||||
|
// args 是 H5 传过来的参数数组,第一个元素通常是对象
|
||||||
|
final payload = args.isNotEmpty ? args.first : null;
|
||||||
|
talker.debug('[JS→Flutter] onReportAction called, payload: $payload');
|
||||||
|
|
||||||
|
// 解析 action 字段
|
||||||
|
String? action;
|
||||||
|
String? data;
|
||||||
|
if (payload is Map) {
|
||||||
|
action = payload['action']?.toString();
|
||||||
|
data = payload['data']?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'share':
|
||||||
|
// TODO: 接 share_plus 分享报告链接
|
||||||
|
talker.debug('[JS→Flutter] → share, data: $data');
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
// TODO: 下载/保存报告文件
|
||||||
|
talker.debug('[JS→Flutter] → download, data: $data');
|
||||||
|
break;
|
||||||
|
case 'refresh':
|
||||||
|
// H5 处理完后要求 Flutter 刷列表并回退
|
||||||
|
talker.debug('[JS→Flutter] → refresh');
|
||||||
|
if (context.mounted) {
|
||||||
|
context.canPop() ? context.pop(true) : context.go('/myReport');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
talker.debug('[JS→Flutter] → unknown action: $action');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 3. 更多 handler 按需添加 ----
|
||||||
|
// 模板(复制下面这几行,改 handlerName 和 callback 即可):
|
||||||
|
//
|
||||||
|
// controller.addJavaScriptHandler(
|
||||||
|
// handlerName: 'yourHandlerName',
|
||||||
|
// callback: (args) {
|
||||||
|
// // TODO: 你的处理逻辑
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_webController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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/version/app_version_service.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【AboutAppPage】关于APP页 —— 对齐 Vue pages/myPackage/aboutApp/index.vue
|
||||||
|
///
|
||||||
|
/// 菜单:隐私政策 / 用户协议 / 版本号(只读)
|
||||||
|
/// 底部:APP备案号
|
||||||
|
/// ============================================================
|
||||||
|
class AboutAppPage extends ConsumerStatefulWidget {
|
||||||
|
const AboutAppPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AboutAppPage> createState() => _AboutAppPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutAppPageState extends ConsumerState<AboutAppPage> {
|
||||||
|
String _version = '';
|
||||||
|
static const _recordNumber = '京ICP备2026019420号-2A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVersion() async {
|
||||||
|
final service = AppVersionService.instance;
|
||||||
|
if (service.versionName.isEmpty) {
|
||||||
|
await service.init();
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _version = service.versionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F8FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('关于APP'),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: EdgeInsets.all(12.w),
|
||||||
|
children: [
|
||||||
|
// 隐私政策
|
||||||
|
_buildRow(
|
||||||
|
title: '隐私政策',
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 22,
|
||||||
|
color: Color(0xFF7FB0F2),
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/privacy-policy'),
|
||||||
|
showBorder: true,
|
||||||
|
),
|
||||||
|
// 用户协议
|
||||||
|
_buildRow(
|
||||||
|
title: '用户协议',
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 22,
|
||||||
|
color: Color(0xFF7FB0F2),
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/user-agreement'),
|
||||||
|
showBorder: true,
|
||||||
|
),
|
||||||
|
// 版本号(只读,无跳转)
|
||||||
|
_buildRow(
|
||||||
|
title: '版本号',
|
||||||
|
trailing: Text(
|
||||||
|
_version,
|
||||||
|
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF687FAD)),
|
||||||
|
),
|
||||||
|
onTap: null,
|
||||||
|
showBorder: false,
|
||||||
|
),
|
||||||
|
SizedBox(height: 48.h),
|
||||||
|
// 备案号
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'APP备案编号:$_recordNumber',
|
||||||
|
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF687FAD)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow({
|
||||||
|
required String title,
|
||||||
|
required Widget trailing,
|
||||||
|
required VoidCallback? onTap,
|
||||||
|
required bool showBorder,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 52.h,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16.sp,
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showBorder)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||||
|
child: Container(height: 1, color: const Color(0xFFEDF3F7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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/components/app_update_dialog.dart';
|
||||||
|
import 'package:new_learn/core/components/confirm_box.dart';
|
||||||
|
import 'package:new_learn/core/network/dio_client.dart';
|
||||||
|
import 'package:new_learn/core/providers/user_provider.dart';
|
||||||
|
import 'package:new_learn/core/storage/user_storage.dart';
|
||||||
|
import 'package:new_learn/core/version/app_version_service.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【SettingsPage】设置页 —— 对齐 Vue pages/myPackage/settings/index.vue
|
||||||
|
///
|
||||||
|
/// 三栏菜单:
|
||||||
|
/// 检查更新(右侧显示当前版本号,手动触发版本比对弹窗)
|
||||||
|
/// 关于APP (TODO: 跳转关于页)
|
||||||
|
/// 注销账号 (Vue 端受 isAuditMode 控制,Flutter 暂先显示)
|
||||||
|
///
|
||||||
|
/// 样式:白色圆角卡片 + 分隔线,和 my_page 的 _MenuCard 风格一致
|
||||||
|
/// ============================================================
|
||||||
|
class SettingsPage extends ConsumerStatefulWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
|
String _version = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVersion() async {
|
||||||
|
final service = AppVersionService.instance;
|
||||||
|
if (service.versionName.isEmpty) {
|
||||||
|
await service.init();
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _version = service.versionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCheckUpdate() async {
|
||||||
|
// 手动检查更新:复用 AppVersionService + AppUpdateDialog
|
||||||
|
try {
|
||||||
|
final service = AppVersionService.instance;
|
||||||
|
await service.init();
|
||||||
|
final update = await service.checkForUpdate();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (update == null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已是最新版本')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await AppUpdateDialog.show(
|
||||||
|
context,
|
||||||
|
update,
|
||||||
|
isForce: service.isForceUpdate(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('检查更新失败:$e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCloseAccount() async {
|
||||||
|
final ok = await showConfirmBox(
|
||||||
|
context,
|
||||||
|
title: '注销提醒',
|
||||||
|
content: '账号注销后,将会删除账号的全部数据,不可恢复。请确认是否注销账号?',
|
||||||
|
confirmText: '确定注销',
|
||||||
|
cancelText: '我再想想',
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await UserApi.closeAccount();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('注销失败:$e')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 清理本地登录态 → 路由守卫自动踢回登录页
|
||||||
|
DioClient.instance.accessToken = null;
|
||||||
|
await UserStorage.instance.clearAll();
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(currentUserProvider.notifier).set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F8FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('设置'),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: EdgeInsets.all(12.w),
|
||||||
|
children: [
|
||||||
|
// 检查更新
|
||||||
|
_buildRow(
|
||||||
|
title: '检查更新',
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_version.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'v$_version',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
color: const Color(0xFF687FAD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 4.w),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 22,
|
||||||
|
color: Color(0xFF7FB0F2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: _handleCheckUpdate,
|
||||||
|
),
|
||||||
|
_buildDivider(),
|
||||||
|
// 关于APP
|
||||||
|
_buildRow(
|
||||||
|
title: '关于APP',
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 22,
|
||||||
|
color: Color(0xFF7FB0F2),
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/about-app'),
|
||||||
|
),
|
||||||
|
_buildDivider(),
|
||||||
|
// 注销账号
|
||||||
|
_buildRow(
|
||||||
|
title: '注销账号',
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 22,
|
||||||
|
color: Color(0xFF7FB0F2),
|
||||||
|
),
|
||||||
|
onTap: _handleCloseAccount,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDivider() {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||||
|
child: Container(height: 1, color: const Color(0xFFEDF3F7)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow({
|
||||||
|
required String title,
|
||||||
|
required Widget trailing,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 52.h,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF1D2B47)),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
--- a/android/build.gradle
|
||||||
|
+++ b/android/build.gradle
|
||||||
|
@@ -41,14 +41,14 @@
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
minifyEnabled false
|
||||||
|
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||