feat: 新增我的报告模块,集成inappwebview支持H5详情页
1. 添加flutter_inappwebview依赖并配置各平台插件注册 2. 新增我的报告列表页、详情WebView页 3. 新增报告模块API和数据模型 4. 配置路由跳转,修复我的菜单跳转逻辑 5. 添加AGP9兼容补丁修复插件构建问题 6. 注释掉登录成功的SnackBar提示
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -23,3 +23,33 @@ plugins {
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
// ============================================================
|
||||
// 【Gradle 兼容补丁】flutter_inappwebview_android 1.1.3 的 AGP 9 兼容问题
|
||||
//
|
||||
// 问题:AGP 9 不支持 proguard-android.txt,必须用 proguard-android-optimize.txt。
|
||||
// 插件 build.gradle 写死了 proguard-android.txt → Gradle 解析时就报错
|
||||
// (debug 构建也炸,因为 AGP 9 在解析阶段就校验了这个方法调用)。
|
||||
//
|
||||
// 修复:在 Gradle 解析插件 build.gradle 之前(beforeProject 钩子),
|
||||
// 把插件文件里的字符串替换掉。这个钩子比 Gradle 自带机制还早触发,
|
||||
// 所以能赶在 Gradle 报错前修好。
|
||||
//
|
||||
// 优势:
|
||||
// - Gradle 原生,不依赖 Python/PowerShell
|
||||
// - 跨平台(Windows/Mac/Linux,Gradle 必备)
|
||||
// - 所有同事拉代码后自动生效
|
||||
// - Gradle daemon 每次构建都会重新解析 build.gradle,不会有缓存问题
|
||||
// ============================================================
|
||||
gradle.beforeProject { Project project ->
|
||||
if (project.name == 'flutter_inappwebview_android') {
|
||||
def gradleFile = project.file('build.gradle')
|
||||
def text = gradleFile.text
|
||||
if (text.contains("proguard-android.txt")) {
|
||||
gradleFile.text = text.replace(
|
||||
"proguard-android.txt", "proguard-android-optimize.txt"
|
||||
)
|
||||
println "✅ Patched flutter_inappwebview_android/build.gradle (AGP 9 compat)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import 'package:new_learn/features/class/my_update_detail_page.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/features/my/my_page.dart';
|
||||
import 'package:new_learn/features/my/my_point/my_point_page.dart';
|
||||
import 'package:new_learn/features/my/my_report/my_report_page.dart';
|
||||
import 'package:new_learn/features/my/my_report/report_detail_page.dart';
|
||||
import 'package:new_learn/features/my/settings/settings_page.dart';
|
||||
import 'package:new_learn/features/my/settings/about_app_page.dart';
|
||||
import 'package:new_learn/features/recite/recite_page.dart';
|
||||
@@ -125,6 +127,18 @@ final GoRouter appRouter = GoRouter(
|
||||
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
|
||||
),
|
||||
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
||||
GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()),
|
||||
GoRoute(
|
||||
path: '/report-detail',
|
||||
builder: (_, state) {
|
||||
// extra 格式:{'title': '...', 'url': '...'}
|
||||
final extra = state.extra as Map<String, dynamic>? ?? {};
|
||||
return ReportDetailPage(
|
||||
title: extra['title'] as String? ?? '我的报告',
|
||||
url: extra['url'] as String? ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
|
||||
// ---------- 首次启动隐私协议页 ----------
|
||||
|
||||
@@ -395,14 +395,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
if (mounted) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
context.go('/');
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}',
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
// messenger.showSnackBar(
|
||||
// SnackBar(
|
||||
// content: Text(
|
||||
// '登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}',
|
||||
// ),
|
||||
// duration: const Duration(seconds: 2),
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) _toast(e.message);
|
||||
|
||||
@@ -101,7 +101,7 @@ class MyPage extends ConsumerWidget {
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
|
||||
title: '我的报告',
|
||||
onTap: () {
|
||||
// TODO: 跳转到我的报告页
|
||||
context.push('/myReport');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/app.dart';
|
||||
@@ -108,6 +109,9 @@ Future<void> main() async {
|
||||
// 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前!
|
||||
// 作用: 初始化 Flutter 和原生的绑定桥梁
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// flutter_inappwebview 平台实现注册(Chrome Web / Windows desktop 必须显式触发)
|
||||
// 否则会报 InAppWebViewPlatform.instance != null 断言错误
|
||||
InAppWebViewPlatform.instance;
|
||||
// go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器
|
||||
// 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用
|
||||
// appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -499,6 +499,70 @@ packages:
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "0.1.5+1"
|
||||
flutter_inappwebview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_android
|
||||
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_internal_annotations
|
||||
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
flutter_inappwebview_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_ios
|
||||
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_macos
|
||||
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_platform_interface
|
||||
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.3.0+1"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_web
|
||||
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_windows
|
||||
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
||||
@@ -166,6 +166,14 @@ dependencies:
|
||||
# url_launcher:跳转应用商店 / 外部浏览器下载地址
|
||||
url_launcher: ^6.3.1
|
||||
|
||||
# WebView(内嵌 H5 报告详情页,支持 JS ↔ Flutter 双向通信)
|
||||
# 为什么用 flutter_inappwebview 而不是 webview_flutter:
|
||||
# - 需要 H5 内部按钮回调到 Flutter(JS bridge:addJavaScriptHandler)
|
||||
# - 比官方 webview_flutter 功能强:支持 callAsyncJavaScript、cookie 管理等
|
||||
# - 6.1.5 stable 最低 Flutter 3.24 / Dart 3.5,项目 Flutter 3.44.8 完全兼容
|
||||
# ⚠ Android 端要求 minSdkVersion >= 19(flutter_inappwebview 6.x 默认)
|
||||
flutter_inappwebview: ^6.1.5
|
||||
|
||||
# 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动)
|
||||
# 用法:lib/core/components/persistent_snap_sheet.dart 封装组件
|
||||
# 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
FlutterTimezonePluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_inappwebview_windows
|
||||
flutter_secure_storage_windows
|
||||
flutter_timezone
|
||||
gal
|
||||
|
||||
Reference in New Issue
Block a user