合并代码
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user