diff --git a/.trae/skills/vue3-to-flutter/SKILL.md b/.trae/skills/vue3-to-flutter/SKILL.md new file mode 100644 index 0000000..a8e6d7d --- /dev/null +++ b/.trae/skills/vue3-to-flutter/SKILL.md @@ -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` | +| `` / `` 带远程 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`(项目已有 `common_paged_list.dart` 封装)。 diff --git a/assets/img/class/comment-icon.png b/assets/img/class/comment-icon.png new file mode 100644 index 0000000..77c1e6b Binary files /dev/null and b/assets/img/class/comment-icon.png differ diff --git a/assets/img/class/like-check-icon.png b/assets/img/class/like-check-icon.png new file mode 100644 index 0000000..774a835 Binary files /dev/null and b/assets/img/class/like-check-icon.png differ diff --git a/assets/img/class/like-icon.png b/assets/img/class/like-icon.png new file mode 100644 index 0000000..e3fd7c9 Binary files /dev/null and b/assets/img/class/like-icon.png differ diff --git a/assets/img/my/close-icon.png b/assets/img/my/close-icon.png new file mode 100644 index 0000000..4a8f422 Binary files /dev/null and b/assets/img/my/close-icon.png differ diff --git a/assets/img/my/correct-active-icon.png b/assets/img/my/correct-active-icon.png new file mode 100644 index 0000000..8ab986a Binary files /dev/null and b/assets/img/my/correct-active-icon.png differ diff --git a/assets/img/my/correct-inactive-icon.png b/assets/img/my/correct-inactive-icon.png new file mode 100644 index 0000000..af6b041 Binary files /dev/null and b/assets/img/my/correct-inactive-icon.png differ diff --git a/assets/img/my/download-icon.png b/assets/img/my/download-icon.png new file mode 100644 index 0000000..d819877 Binary files /dev/null and b/assets/img/my/download-icon.png differ diff --git a/assets/img/my/fork-icon.png b/assets/img/my/fork-icon.png new file mode 100644 index 0000000..1249f8c Binary files /dev/null and b/assets/img/my/fork-icon.png differ diff --git a/assets/img/my/myScore-bg.png b/assets/img/my/myScore-bg.png new file mode 100644 index 0000000..334d4b4 Binary files /dev/null and b/assets/img/my/myScore-bg.png differ diff --git a/assets/img/my/remove-icon.png b/assets/img/my/remove-icon.png new file mode 100644 index 0000000..cd25c7b Binary files /dev/null and b/assets/img/my/remove-icon.png differ diff --git a/assets/img/my/star-icon.png b/assets/img/my/star-icon.png new file mode 100644 index 0000000..8a9ecd5 Binary files /dev/null and b/assets/img/my/star-icon.png differ diff --git a/assets/img/my/wrong-active-icon.png b/assets/img/my/wrong-active-icon.png new file mode 100644 index 0000000..a05cdd3 Binary files /dev/null and b/assets/img/my/wrong-active-icon.png differ diff --git a/assets/img/my/wrong-inactive-icon.png b/assets/img/my/wrong-inactive-icon.png new file mode 100644 index 0000000..50cdc61 Binary files /dev/null and b/assets/img/my/wrong-inactive-icon.png differ diff --git a/lib/core/api/my_report/api.dart b/lib/core/api/my_report/api.dart new file mode 100644 index 0000000..cadaf46 --- /dev/null +++ b/lib/core/api/my_report/api.dart @@ -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 getReportPage({ + int pageIndex = 1, + int pageSize = 10, + int? reportType, + }) async { + final query = { + '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); + } +} diff --git a/lib/core/api/my_report/model.dart b/lib/core/api/my_report/model.dart new file mode 100644 index 0000000..20a9b7f --- /dev/null +++ b/lib/core/api/my_report/model.dart @@ -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 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 data; + final int total; + final int pageIndex; + final int pageSize; + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory UserReportPage.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return UserReportPage( + data: (payload['data'] as List? ?? const []) + .whereType>() + .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, + ); + } +} diff --git a/lib/core/components/center_toast.dart b/lib/core/components/center_toast.dart new file mode 100644 index 0000000..368ec90 --- /dev/null +++ b/lib/core/components/center_toast.dart @@ -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.delayed(duration, () { + if (entry.mounted && _current == entry) { + entry.remove(); + _current = null; + } + }); + } +} diff --git a/lib/core/components/resolve_problem_box.dart b/lib/core/components/resolve_problem_box.dart new file mode 100644 index 0000000..6fa9a97 --- /dev/null +++ b/lib/core/components/resolve_problem_box.dart @@ -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), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/register_page.dart b/lib/features/auth/register_page.dart new file mode 100644 index 0000000..e8a5bea --- /dev/null +++ b/lib/features/auth/register_page.dart @@ -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 createState() => _RegisterPageState(); +} + +class _RegisterPageState extends ConsumerState { + // ---------- 表单控制器 ---------- + 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 _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.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? 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)), + ), + ), + ); + } +} diff --git a/lib/features/my/my_report/my_report_page.dart b/lib/features/my/my_report/my_report_page.dart new file mode 100644 index 0000000..3366a2a --- /dev/null +++ b/lib/features/my/my_report/my_report_page.dart @@ -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 _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 createState() => _MyReportPageState(); +} + +class _MyReportPageState extends ConsumerState { + static const int _pageSize = 10; + + @override + void initState() { + super.initState(); + talker.debug('打开页面:我的报告'); + } + + /// 分页加载回调 + Future> _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( + 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, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/my/my_report/report_detail_page.dart b/lib/features/my/my_report/report_detail_page.dart new file mode 100644 index 0000000..c87130b --- /dev/null +++ b/lib/features/my/my_report/report_detail_page.dart @@ -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 createState() => _ReportDetailPageState(); +} + +class _ReportDetailPageState extends State { + 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(); + } +} diff --git a/lib/features/my/settings/about_app_page.dart b/lib/features/my/settings/about_app_page.dart new file mode 100644 index 0000000..0ee8bb6 --- /dev/null +++ b/lib/features/my/settings/about_app_page.dart @@ -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 createState() => _AboutAppPageState(); +} + +class _AboutAppPageState extends ConsumerState { + String _version = ''; + static const _recordNumber = '京ICP备2026019420号-2A'; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _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)), + ), + ], + ), + ); + } +} diff --git a/lib/features/my/settings/settings_page.dart b/lib/features/my/settings/settings_page.dart new file mode 100644 index 0000000..f9e88f8 --- /dev/null +++ b/lib/features/my/settings/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + String _version = ''; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + final service = AppVersionService.instance; + if (service.versionName.isEmpty) { + await service.init(); + } + if (mounted) { + setState(() => _version = service.versionName); + } + } + + Future _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 _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, + ], + ), + ), + ); + } +} diff --git a/patches/flutter_inappwebview_android-1.1.3.patch b/patches/flutter_inappwebview_android-1.1.3.patch new file mode 100644 index 0000000..f26e04e --- /dev/null +++ b/patches/flutter_inappwebview_android-1.1.3.patch @@ -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' + } + }