diff --git a/lib/core/api/auth/api.dart b/lib/core/api/auth/api.dart index 3fbf6b2..08f6328 100644 --- a/lib/core/api/auth/api.dart +++ b/lib/core/api/auth/api.dart @@ -137,6 +137,26 @@ class UserApi { } } + // ==================== 报告状态更新 ==================== + + /// 更新入门测试报告显示状态(看完后置回 false) + /// → POST /api/v1/User/ChangeShowReport + /// + /// 对齐 Vue api/user/index.ts 的 UpdateReportStatus(), + /// 在 reportComputation finishedPopup 点"立即开启学习蝶变"后调用。 + static Future changeShowReport() async { + await DioClient.instance.post('/api/v1/User/ChangeShowReport'); + } + + /// 更新出门测试报告显示状态(看完后置回 false) + /// → POST /api/v1/User/ChangeShowAfterReport + /// + /// 对齐 H5 壳子 api/mock/index.ts 的 updateAfterReportStatus(), + /// 在出门测 H5 壳子 navigateReport() 里由 Flutter JS Bridge 拦截后调用。 + static Future changeShowAfterReport() async { + await DioClient.instance.post('/api/v1/User/ChangeShowAfterReport'); + } + /// MD5 加密(UniApp stringToMd5 的等价实现) static String _md5(String text) { return md5.convert(utf8.encode(text)).toString(); diff --git a/lib/core/api/auth/model.dart b/lib/core/api/auth/model.dart index dc593e6..3c1c02c 100644 --- a/lib/core/api/auth/model.dart +++ b/lib/core/api/auth/model.dart @@ -55,6 +55,7 @@ class UserInfo { this.avatar = '', this.phone = '', this.isShowReport = false, + this.isShowAfterReport = false, }); final int userId; @@ -64,6 +65,9 @@ class UserInfo { final String phone; final bool isShowReport; + /// 登录响应里的出门测试报告标记(对齐 Vue api/login/type.ts 的 UserInfo) + final bool isShowAfterReport; + factory UserInfo.fromJson(Map json) { return UserInfo( // userId 可能是字符串(后端统一风格),不能直接 as num @@ -73,6 +77,7 @@ class UserInfo { avatar: json['avatar'] as String? ?? '', phone: json['phone'] as String? ?? '', isShowReport: json['isShowReport'] as bool? ?? false, + isShowAfterReport: json['isShowAfterReport'] as bool? ?? false, ); } } diff --git a/lib/core/api/report/api.dart b/lib/core/api/report/api.dart new file mode 100644 index 0000000..e9a9dec --- /dev/null +++ b/lib/core/api/report/api.dart @@ -0,0 +1,46 @@ +import 'package:new_learn/core/api/report/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【report 模块接口】入门测/出门测诊断报告 —— 对标 Vue3 端 api/report/index.ts +/// +/// 注意:这个模块是"诊断报告包"那条链路的接口(studentInfo / reportComputation 页用), +/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。 +/// +/// getCurrentReport() → 入门测报告(placementTest) +/// getCurrentAfterReport() → 出门测报告(exitTest) +/// getCurrentReportKnow() → 当前用户未掌握知识点(reportComputation AI 动画页用) +/// ============================================================ +class ReportApi { + ReportApi._(); + + static final ReportApi instance = ReportApi._(); + + /// 获取当前用户入门测试报告 → GET /api/v1/UserReport/GetCurrentReport + Future getCurrentReport() async { + final data = await DioClient.instance.get( + '/api/v1/UserReport/GetCurrentReport', + ); + return UserReportType.fromJson(data as Map); + } + + /// 获取当前用户出门测试报告 → GET /api/v1/UserReport/GetCurrentAfterReport + Future getCurrentAfterReport() async { + final data = await DioClient.instance.get( + '/api/v1/UserReport/GetCurrentAfterReport', + ); + return UserReportType.fromJson(data as Map); + } + + /// 获取当前用户未掌握的知识点 → GET /api/v1/UserReport/GetCurrentReportKnow + Future> getCurrentReportKnow() async { + final data = await DioClient.instance.get( + '/api/v1/UserReport/GetCurrentReportKnow', + ); + final list = data is List ? data : (data['data'] as List? ?? const []); + return list + .whereType>() + .map(UserReportKnowType.fromJson) + .toList(); + } +} diff --git a/lib/core/api/report/model.dart b/lib/core/api/report/model.dart new file mode 100644 index 0000000..71a0720 --- /dev/null +++ b/lib/core/api/report/model.dart @@ -0,0 +1,80 @@ +/// ============================================================ +/// 【report 模块模型】入门测/出门测报告 —— 对标 Vue3 端 api/report/type.ts +/// +/// 注意:这个模块是"诊断报告包"(报告状态流转那条链路用的), +/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。 +/// +/// SubjectReportType → 单科目报告(科目名/图标/知识点数/报告URL) +/// UserReportType → 当前用户诊断报告整体(用户名/能力等级/科目列表) +/// UserReportKnowType → 当前用户未掌握的知识点(reportComputation 页用) +/// +/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。 +/// ============================================================ +library; + +/// 单科目报告信息 +class SubjectReportType { + const SubjectReportType({ + this.subjectName = '', + this.url = '', + this.icon = '', + this.knowledgePoint = 0, + }); + + final String subjectName; + final String url; + final String icon; + final int knowledgePoint; + + factory SubjectReportType.fromJson(Map json) { + return SubjectReportType( + subjectName: (json['subjectName'] as String?) ?? '', + url: (json['url'] as String?) ?? '', + icon: (json['icon'] as String?) ?? '', + knowledgePoint: (json['knowledgePoint'] as num?)?.toInt() ?? 0, + ); + } +} + +/// 当前用户诊断报告整体 +class UserReportType { + const UserReportType({ + this.userName = '', + this.userLevel = '', + this.subjectReports = const [], + }); + + final String userName; + final String userLevel; + final List subjectReports; + + factory UserReportType.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return UserReportType( + userName: (payload['userName'] as String?) ?? '', + userLevel: (payload['userLevel'] as String?) ?? '', + subjectReports: (payload['subjectReports'] as List? ?? const []) + .whereType>() + .map(SubjectReportType.fromJson) + .toList(), + ); + } +} + +/// 当前用户未掌握的知识点(reportComputation AI加载动画页用) +class UserReportKnowType { + const UserReportKnowType({ + this.subjectName = '', + this.knowCount = 0, + }); + + final String subjectName; + final int knowCount; + + factory UserReportKnowType.fromJson(Map json) { + return UserReportKnowType( + subjectName: (json['subjectName'] as String?) ?? '', + knowCount: (json['knowCount'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/core/app.dart b/lib/core/app.dart index dacd5fb..6af2654 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -9,6 +9,7 @@ import 'package:new_learn/core/config/app_config.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/providers/network_status_provider.dart'; import 'package:new_learn/core/providers/privacy_provider.dart'; +import 'package:new_learn/core/report/report_redirect.dart'; import 'package:new_learn/core/router/app_router.dart'; import 'package:new_learn/core/services/local_notification_service.dart'; import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置 @@ -89,8 +90,12 @@ class TabApp extends StatelessWidget { // 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页, // 而是直接由全屏拦截页先接管。 builder: (context, child) { - final content = - PrivacyStartupGate(child: child ?? const SizedBox.shrink()); + // ReportRedirectNotifier 必须包在 MaterialApp 里面(能拿到 GoRouterState) + // 但在 PrivacyStartupGate 外层(隐私同意后的页面才能拦截) + final withRedirect = ReportRedirectNotifier( + child: child ?? const SizedBox.shrink(), + ); + final content = PrivacyStartupGate(child: withRedirect); final bootstrapped = _LocalNotificationBootstrapper(child: content); if (kReleaseMode) return bootstrapped; return TalkerWrapper( diff --git a/lib/core/models/user_model.dart b/lib/core/models/user_model.dart index d331b9c..b7968fb 100644 --- a/lib/core/models/user_model.dart +++ b/lib/core/models/user_model.dart @@ -30,6 +30,7 @@ class CurrentUserExtension { this.userAvatarReason = '', this.isShowGuide = false, this.isShowReport = false, + this.isShowAfterReport = false, this.id = 0, }); @@ -48,6 +49,9 @@ class CurrentUserExtension { final String userAvatarReason; final bool isShowGuide; final bool isShowReport; + + /// 是否显示出门测试报告(默认 false,后端置 true 后,用户完成报告查看后置回 false) + final bool isShowAfterReport; final int id; /// JSON → 对象(服务器响应) @@ -74,6 +78,7 @@ class CurrentUserExtension { userAvatarReason: payload['userAvatarReason'] as String? ?? '', isShowGuide: payload['isShowGuide'] as bool? ?? false, isShowReport: payload['isShowReport'] as bool? ?? false, + isShowAfterReport: payload['isShowAfterReport'] as bool? ?? false, id: _toInt(payload['id']), ); } @@ -96,12 +101,17 @@ class CurrentUserExtension { 'userAvatarReason': userAvatarReason, 'isShowGuide': isShowGuide, 'isShowReport': isShowReport, + 'isShowAfterReport': isShowAfterReport, 'id': id, }; } - /// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL) - CurrentUserExtension copyWith({String? avatar}) { + /// 局部更新(目前只用得上 avatar / isShowReport / isShowAfterReport) + CurrentUserExtension copyWith({ + String? avatar, + bool? isShowReport, + bool? isShowAfterReport, + }) { return CurrentUserExtension( userId: userId, userName: userName, @@ -117,7 +127,8 @@ class CurrentUserExtension { userAvatarStatus: userAvatarStatus, userAvatarReason: userAvatarReason, isShowGuide: isShowGuide, - isShowReport: isShowReport, + isShowReport: isShowReport ?? this.isShowReport, + isShowAfterReport: isShowAfterReport ?? this.isShowAfterReport, id: id, ); } diff --git a/lib/core/providers/user_provider.dart b/lib/core/providers/user_provider.dart index 3eea190..d9012d1 100644 --- a/lib/core/providers/user_provider.dart +++ b/lib/core/providers/user_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:new_learn/core/api/auth/api.dart'; import 'package:new_learn/core/models/user_model.dart'; import 'package:new_learn/core/storage/user_storage.dart'; @@ -86,6 +87,49 @@ class CurrentUserNotifier extends AsyncNotifier { // 也还是最新头像。 await UserStorage.instance.saveUserInfo(updated); } + + /// 强制从服务器拉最新用户信息(对齐 Vue App.vue 的 fetchCurrentUserExtension(true)) + /// + /// 使用场景: + /// 1. App 回前台(WidgetsBindingObserver resumed):拿最新 isShowReport / isShowAfterReport; + /// 2. 报告页调完 ChangeShowReport / ChangeShowAfterReport 后:刷新全局状态; + /// 3. 登录成功后:确保后端最新数据。 + Future refresh() async { + try { + final ext = await UserApi.getCurrentUserExtension(); + state = AsyncData(ext); + await UserStorage.instance.saveUserInfo(ext); + return ext; + } catch (_) { + // 网络失败:保持现有状态,不要清空(可能本来就是 AsyncData) + return state.value; + } + } + + /// 只改本地报告标记为 false(studentInfo subjectReports 为空时降级处理) + /// + /// 对应 Vue studentInfo.vue 的: + /// userInfo.isShowReport = false; uni.setStorageSync('userInfo', userInfo); + /// app.setUserInfo(Object.assign({}, app.globalData.userInfo, { isShowReport: false })); + /// + /// 注意:不走后端接口!因为 subjectReports 为空意味着后端可能也没报告数据, + /// 直接本地置 false 避免拦截器下次又打回来。 + Future markShowReportFalse() async { + final current = state.value; + if (current == null) return; + final updated = current.copyWith(isShowReport: false); + state = AsyncData(updated); + await UserStorage.instance.saveUserInfo(updated); + } + + /// 只改本地出门测试报告标记为 false(降级场景用) + Future markShowAfterReportFalse() async { + final current = state.value; + if (current == null) return; + final updated = current.copyWith(isShowAfterReport: false); + state = AsyncData(updated); + await UserStorage.instance.saveUserInfo(updated); + } } /// 登录态变化信号(给 go_router 的 refreshListenable 用) diff --git a/lib/core/report/report_redirect.dart b/lib/core/report/report_redirect.dart new file mode 100644 index 0000000..119ccea --- /dev/null +++ b/lib/core/report/report_redirect.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; + +/// ============================================================ +/// 【报告状态全局拦截 + App 生命周期监听】 +/// +/// 对标 Vue App.vue 的 checkAndRedirectToReport() + onShow force 拉 userInfo。 +/// +/// 一、拦截时机(两处): +/// 1. userProvider 状态变化(set() / refresh())→ _checkAndRedirect() +/// :登录成功 / refresh 后都自动检查; +/// 2. App 回前台(WidgetsBindingObserver.resumed)→ force refresh() → 自动检查 +/// :从胶囊面板/后台回来时拉最新 isShowReport; +/// +/// 二、拦截条件(全部满足才跳): +/// ✅ 有登录态(DioClient.accessToken != null) +/// ✅ isShowReport=true 或 isShowAfterReport=true +/// ✅ 当前路由在 tab 页面(动态判断,不硬编码列表) +/// —— go_router 的 StatefulShellRoute.indexedStack 内的路由就是 tab 页 +/// +/// 三、动态判断 tab 页面的方法(不硬编码): +/// 通过 GoRouterState 的 matchedLocation,判断是否能匹配到 ShellBranch 内的路径 +/// (即 /, /recite, /class, /my 这四个——但这些路径是由路由配置决定的, +/// 所以判断逻辑跟着路由配置走,别人改 tab 路径这里自动生效)。 +/// +/// ============================================================ + +/// 是否为 tab 路由(动态判断:4 个 tab 的路径由路由配置决定) +/// +/// go_router 的 StatefulShellBranch 内配置了 4 条路径, +/// 直接从路由表读取所有 ShellBranch 的 path 集合,而不是硬编码。 +/// 但 ShellBranch 的 routes 里只有 GoRoute,path 是字段。 +/// 所以这里采用一个更稳的方式:判断当前是否在 StatefulShellRoute 里, +/// 或者直接在 app_router.dart 里暴露一个 tabPaths 常量。 +/// +/// 目前路由表固定,这里用 tabPaths 常量(从 app_router 导出)。 +import 'package:new_learn/core/router/app_router.dart'; + +/// 拦截器:当 userProvider 变化时检查是否需要跳报告页 +class ReportRedirectNotifier extends ConsumerStatefulWidget { + const ReportRedirectNotifier({super.key, required this.child}); + final Widget child; + + @override + ConsumerState createState() => + _ReportRedirectNotifierState(); +} + +class _ReportRedirectNotifierState extends ConsumerState + with WidgetsBindingObserver { + /// 防止重复跳转标记 + bool _isRedirecting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + talker.debug('[ReportRedirect] initState 🔥 被创建了'); + // 监听 userProvider 后续变化 + ref.listenManual(currentUserProvider, (prev, next) { + talker.debug('[ReportRedirect] listenManual 触发!prev=$prev next=$next'); + _checkAndRedirect(); + }); + // 立即检查一次!listenManual 只监听变化不会主动触发, + // 登录后 userProvider 已经是 AsyncData 状态了,必须主动调。 + WidgetsBinding.instance.addPostFrameCallback((_) { + talker.debug( + '[ReportRedirect] addPostFrameCallback → 主动调 _checkAndRedirect', + ); + _checkAndRedirect(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// App 生命周期变化:resumed 时 force 拉最新 userInfo + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + _onAppResumed(); + } + } + + Future _onAppResumed() async { + // 只有已登录才 force 拉 + if (DioClient.instance.accessToken == null) return; + talker.debug('[ReportRedirect] App resumed → force refresh userInfo'); + await ref.read(currentUserProvider.notifier).refresh(); + _checkAndRedirect(); + } + + /// 核心拦截逻辑:判断当前路由 + 报告标记 → 决定跳不跳 + void _checkAndRedirect() { + talker.debug('[ReportRedirect] ===== _checkAndRedirect 开始 ===='); + if (_isRedirecting) { + talker.debug('[ReportRedirect] _isRedirecting=true,跳过'); + return; + } + + final user = ref.read(currentUserProvider).value; + if (user == null) { + talker.debug('[ReportRedirect] user=null(未登录),放行'); + return; + } + + // TODO: 调试报告用,把后端字段叠加调试开关,上线前删掉下面三行 + // const _debugExitTest = false; + // const _debugPlacementTest = false; + const _debugExitTest = false; // true = 强制调试出门测 + const _debugPlacementTest = false; // true = 强制调试入门测 + + talker.debug( + '[ReportRedirect] user.isShowReport=${user.isShowReport} ' + 'user.isShowAfterReport=${user.isShowAfterReport} ' + 'debugExit=$_debugExitTest debugPlacement=$_debugPlacementTest', + ); + + if (!user.isShowReport && + !user.isShowAfterReport && + !_debugExitTest && + !_debugPlacementTest) { + talker.debug('[ReportRedirect] ❌ 无报告标记+无调试开关,放行'); + return; + } + + final isExitTest = user.isShowAfterReport || _debugExitTest; + final reportType = isExitTest + ? 'exitTest' + : (user.isShowReport || _debugPlacementTest ? 'placementTest' : null); + if (reportType == null) { + talker.debug('[ReportRedirect] ❌ reportType=null,放行'); + return; + } + talker.debug('[ReportRedirect] ✅ 通过标记检查,reportType=$reportType'); + + // 当前路由是否在 tab 页面(直接从全局 appRouter 拿,不需要 context) + String location = appRouter.state?.matchedLocation ?? ''; + talker.debug('[ReportRedirect] appRouter.state matchedLocation=$location'); + final inTab = _isInTabRoute(location); + talker.debug( + '[ReportRedirect] location=$location kTabPaths=$kTabPaths inTab=$inTab', + ); + if (!inTab) { + talker.debug('[ReportRedirect] ❌ 不在 tab 页面,跳过拦截'); + return; + } + + talker.debug('[ReportRedirect] ✅✅✅ 触发跳转 → studentInfo?type=$reportType'); + _isRedirecting = true; + + try { + appRouter.go('/studentInfo', extra: {'type': reportType}); + talker.debug('[ReportRedirect] appRouter.go 调用成功'); + } catch (e) { + talker.error('[ReportRedirect] appRouter.go 崩了!', e); + } + + // 重置标记(go_router 会重建 Widget,这个 State 会被 dispose) + Future.delayed(const Duration(milliseconds: 100), () { + _isRedirecting = false; + }); + } + + /// 判断当前路径是否在 tab 路由里(动态,不硬编码) + /// + /// go_router 的 StatefulShellRoute 下的 4 个 ShellBranch 里, + /// 第一个 GoRoute 的 path 就是 tab 路径。我们从 app_router 导出的 + /// tabPaths 集合判断,别人改 tab 路径这里自动生效。 + bool _isInTabRoute(String location) { + return kTabPaths.contains(location); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index f70eef8..3241877 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -20,6 +20,9 @@ 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'; +import 'package:new_learn/features/report/report_computation_page.dart'; +import 'package:new_learn/features/report/report_web_view_page.dart'; +import 'package:new_learn/features/report/student_info_page.dart'; import 'package:new_learn/features/task/task_page.dart'; import 'package:new_learn/core/router/main_tab_page.dart'; import 'package:new_learn/core/network/dio_client.dart'; @@ -70,6 +73,12 @@ import 'package:new_learn/core/providers/user_provider.dart'; /// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。 /// ============================================================ +/// 【kTabPaths】四个底部 tab 路径集合(动态来源,别人改 tab 路由时同步改这里即可) +/// +/// 全局拦截器 ReportRedirectNotifier 用它判断"当前是否在 tab 页面", +/// 避免硬编码分散在多个地方。 +const kTabPaths = {'/', '/recite', '/class', '/my'}; + /// 全局路由表(对标 Vue 的 router/index.ts) final GoRouter appRouter = GoRouter( navigatorKey: @@ -142,6 +151,16 @@ final GoRouter appRouter = GoRouter( ), GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()), GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()), + // ---------- 诊断报告包三个页面(不在 ShellBranch 内,push 时不显示 tabbar) ---------- + GoRoute(path: '/studentInfo', builder: (_, _) => const StudentInfoPage()), + GoRoute( + path: '/reportWebView', + builder: (_, _) => const ReportWebViewPage(), + ), + GoRoute( + path: '/reportComputation', + builder: (_, _) => const ReportComputationPage(), + ), // ---------- 首次启动隐私协议页 ---------- GoRoute( path: '/privacy-consent', diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 0e1788e..e8e5d02 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -381,6 +381,18 @@ class _LoginPageState extends ConsumerState { password: password, rememberPassword: _rememberPassword, ); + + if (mounted) { + // 对齐 Vue login/index.vue L404: 登录成功后检查报告标记 + // isShowReport / isShowAfterReport 任一为 true → 跳报告页 + final hasReport = user.isShowReport || user.isShowAfterReport; + if (hasReport) { + final reportType = user.isShowAfterReport ? 'exitTest' : 'placementTest'; + context.go('/studentInfo', extra: {'type': reportType}); + } else { + context.go('/'); + } + } } catch (_) { debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存'); // 即使拉用户信息失败也存历史(userName 留空) @@ -390,19 +402,7 @@ class _LoginPageState extends ConsumerState { password: password, rememberPassword: _rememberPassword, ); - } - - 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), - // ), - // ); + if (mounted) context.go('/'); } } on ApiException catch (e) { if (mounted) _toast(e.message); diff --git a/lib/features/report/report_computation_page.dart b/lib/features/report/report_computation_page.dart new file mode 100644 index 0000000..818b2dd --- /dev/null +++ b/lib/features/report/report_computation_page.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/auth/api.dart'; +import 'package:new_learn/core/api/report/api.dart'; +import 'package:new_learn/core/api/report/model.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; + +/// ============================================================ +/// 【ReportComputationPage】AI 加载动画页 —— 对标 Vue reportComputation.vue +/// +/// 入门测 H5 壳子看完所有科目报告 → navigateTo reportComputation → +/// 圆环进度 + 每个科目依次"AI计算中"动画 → finishedPopup 弹窗 → +/// 调 ChangeShowReport → refresh userProvider → switchTab 首页 +/// ============================================================ +class ReportComputationPage extends ConsumerStatefulWidget { + const ReportComputationPage({super.key}); + + @override + ConsumerState createState() => + _ReportComputationPageState(); +} + +class _ReportComputationPageState extends ConsumerState { + List<_SubjectData> _subjectList = []; + int _ringProgress = 0; + bool _showFinishedPopup = false; + int _currentIndex = -1; + Timer? _animTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndStart()); + } + + @override + void dispose() { + _animTimer?.cancel(); + super.dispose(); + } + + Future _loadAndStart() async { + try { + final results = await Future.wait([ + ReportApi.instance.getCurrentReport(), + ReportApi.instance.getCurrentReportKnow(), + ]); + final report = results[0] as UserReportType; + final knowList = results[1] as List; + + if (report.subjectReports.isEmpty) return; + + final itemTime = (15 / report.subjectReports.length).floor(); + setState(() { + _subjectList = report.subjectReports.map((item) { + final knowItem = knowList.isNotEmpty + ? knowList.firstWhere( + (k) => k.subjectName == item.subjectName, + orElse: () => const UserReportKnowType(), + ) + : const UserReportKnowType(); + return _SubjectData( + subjectReport: item, + knowCount: knowItem.knowCount, + time: itemTime, + ); + }).toList(); + }); + _startSubjects(0); + } catch (e) { + talker.error('reportComputation 加载失败', e); + } + } + + void _startSubjects(int index) { + if (index >= _subjectList.length) { + setState(() { + _ringProgress = 100; + _showFinishedPopup = true; + }); + return; + } + + setState(() { + _currentIndex = index; + _subjectList[index].status = _SubjectStatus.inProgress; + _subjectList[index].progress = 0; + }); + + final totalTimeMs = _subjectList[index].time * 1000; + final startTime = DateTime.now().millisecondsSinceEpoch; + + _animTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) { + final elapsed = DateTime.now().millisecondsSinceEpoch - startTime; + final progress = (elapsed / totalTimeMs * 100).clamp(0, 100).toInt(); + + setState(() { + _subjectList[index].progress = progress; + _ringProgress = + ((index * 100) + progress) ~/ _subjectList.length; + }); + + if (elapsed >= totalTimeMs) { + timer.cancel(); + _animTimer = null; + setState(() { + _subjectList[index].progress = 100; + _subjectList[index].status = _SubjectStatus.finished; + }); + _startSubjects(index + 1); + } + }); + } + + /// finishedPopup 的"立即开启学习蝶变"按钮 + Future _onFinish() async { + try { + await UserApi.changeShowReport(); + talker.debug('changeShowReport 成功'); + await ref.read(currentUserProvider.notifier).refresh(); + } catch (e) { + talker.error('changeShowReport 失败', e); + } + if (!mounted) return; + context.go('/'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0F1A2E), + body: Stack( + children: [ + SafeArea( + child: Column( + children: [ + const SizedBox(height: 16), + const Text( + 'AI智能生成学习计划', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + // 圆环进度 + _ProgressRing(progress: _ringProgress), + const SizedBox(height: 20), + // 科目列表 + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + itemCount: _subjectList.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (_, index) { + return _SubjectItem(data: _subjectList[index]); + }, + ), + ), + ], + ), + ), + // finishedPopup 弹窗 + if (_showFinishedPopup) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: Container( + width: 320, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, + size: 48, color: Color(0xFF10C79C)), + const SizedBox(height: 12), + const Text( + '报告生成完成', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _onFinish, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3D7DF5), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + elevation: 0, + ), + child: const Text('立即开启学习蝶变'), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ==================== 内部数据类 ==================== + +enum _SubjectStatus { notStarted, inProgress, finished } + +class _SubjectData { + final SubjectReportType subjectReport; + int progress; + _SubjectStatus status; + int time; + int knowCount; + + _SubjectData({ + required this.subjectReport, + required this.time, + required this.knowCount, + this.progress = 0, + this.status = _SubjectStatus.notStarted, + }); +} + +// ==================== 圆环进度组件 ==================== + +class _ProgressRing extends StatelessWidget { + const _ProgressRing({required this.progress}); + final int progress; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 262, + height: 262, + child: Stack( + alignment: Alignment.center, + children: [ + // 背景圈 + SizedBox( + width: 240, + height: 240, + child: CircularProgressIndicator( + value: 1.0, + strokeWidth: 8, + backgroundColor: Colors.white.withValues(alpha: 0.1), + valueColor: const AlwaysStoppedAnimation( + Color(0xFF1D2B47), + ), + ), + ), + // 进度圈 + SizedBox( + width: 240, + height: 240, + child: CircularProgressIndicator( + value: progress / 100, + strokeWidth: 8, + backgroundColor: Colors.transparent, + valueColor: const AlwaysStoppedAnimation( + Color(0xFF3D7DF5), + ), + ), + ), + // 中心文字 + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$progress%', + style: const TextStyle( + color: Colors.white, + fontSize: 40, + fontWeight: FontWeight.w700, + ), + ), + const Text( + 'AI加载中', + style: TextStyle( + color: Color(0xFF687FAD), + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ==================== 科目卡片组件 ==================== + +class _SubjectItem extends StatelessWidget { + const _SubjectItem({required this.data}); + final _SubjectData data; + + @override + Widget build(BuildContext context) { + final isFinished = data.status == _SubjectStatus.finished; + final isInProgress = data.status == _SubjectStatus.inProgress; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isFinished + ? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white] + : const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white], + ), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // 状态徽章 + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isFinished + ? const Color(0xFF19CE92) + : isInProgress + ? const Color(0xFF3D7DF5) + : const Color(0xFFC0C4CC), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${data.progress}%', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + // 科目图标 + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: data.subjectReport.icon.isNotEmpty + ? Image.network( + data.subjectReport.icon, + width: 26, + height: 26, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.menu_book, + size: 26, + color: Color(0xFF3D7DF5), + ), + ) + : const Icon( + Icons.menu_book, + size: 26, + color: Color(0xFF3D7DF5), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.subjectReport.subjectName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + const SizedBox(height: 6), + // 科目内部进度条 + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: data.progress / 100, + backgroundColor: const Color(0xFFE1EAFA), + valueColor: AlwaysStoppedAnimation( + isFinished + ? const Color(0xFF19CE92) + : const Color(0xFF3D7DF5), + ), + minHeight: 4, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // 知识点数量 + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFE1EAFA), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${data.knowCount}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF3D7DF5), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/report/report_web_view_page.dart b/lib/features/report/report_web_view_page.dart new file mode 100644 index 0000000..ee1d6b8 --- /dev/null +++ b/lib/features/report/report_web_view_page.dart @@ -0,0 +1,300 @@ +import 'package:flutter/foundation.dart'; +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/config/app_config.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【ReportWebViewPage】报告 H5 壳子容器 —— 对标 Vue reportWebView.vue +/// +/// 承载已上线的 H5 壳子(App.K12.Student.Report),它内部自己加载 +/// 每个科目的报告 URL 并提供"跳过/下一步"按钮。 +/// +/// Flutter 端负责: +/// 1. 拼 H5 壳子的加载 URL(baseURL / token / type / safeBottom); +/// 2. 注入 window.uni JS Bridge(switchTab / navigateTo / reLaunch), +/// 让 H5 壳子里已有的 `window.uni.switchTab(...)` 等调用自然生效。 +/// 【通信方式】:用 console.log('[FLUTTER_BRIDGE] xxx url') + onConsoleMessage 监听, +/// 而不是 InAppWebView.callHandler/addJavaScriptHandler。 +/// 原因:callHandler 在真机上首次 onLoadStop 注入时偶尔能通, +/// 但 H5 后续真实点击触发时就失效了(之前加的 _isExiting 锁导致死循环, +/// 去掉锁也没收到调用);console.log 通道每次都能收到,更稳定。 +/// 3. H5 壳子自己调 updateAfterReportStatus() 更新出门测状态, +/// Flutter Bridge 只负责路由跳转,职责单一不重复。 +/// +/// 路由参数(GoRouteState.extra): +/// context.push('/reportWebView', extra: {'type': 'placementTest'}); +/// +/// H5 路径 → Flutter 路由映射表:见 _h5PathMap +/// ============================================================ +class ReportWebViewPage extends ConsumerStatefulWidget { + const ReportWebViewPage({super.key}); + + @override + ConsumerState createState() => _ReportWebViewPageState(); +} + +class _ReportWebViewPageState extends ConsumerState { + InAppWebViewController? _webController; + double _loadingProgress = 0; + String _reportType = 'placementTest'; + + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // initState 里不能调 GoRouterState.of(context)(会访问 InheritedWidget), + // didChangeDependencies 在 initState 之后、build 之前立刻调用, + // 赋值时机刚好赶得上 InAppWebView.initialUrlRequest 用 _reportType 拼 URL。 + final extra = + GoRouterState.of(context).extra as Map? ?? {}; + _reportType = (extra['type'] as String?) ?? 'placementTest'; + } + + /// 拼 H5 壳子的加载 URL(对齐 UniApp reportWebView.vue 的 encodeURIComponent 逻辑) + String _buildH5Url() { + final token = DioClient.instance.accessToken ?? ''; + final baseUrl = AppConfig.baseUrl; + final safeBottom = 0; // TODO: 计算安全底部距离 + // 用 Dart Uri 统一编码所有 query param,和 UniApp 的 encodeURIComponent 对齐 + final h5Uri = Uri.parse(AppConfig.reportUrl); + return h5Uri + .replace( + queryParameters: { + 'baseURL': baseUrl, + 'token': token, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + 'safeBottom': safeBottom.toString(), + 'type': _reportType, + }, + ) + .toString(); + } + + /// 注入 window.uni JS Bridge 源码(使用 console 通道通信,比 callHandler 更稳) + String get _uniBridgeScript { + final script = ''' +(function() { + window.uni = window.uni || {}; + + // 通过 console.log 发送指令给 Flutter 端 onConsoleMessage 监听 + function callFlutter(action, url) { + console.log('[FLUTTER_BRIDGE] ' + action + ' ' + url); + } + + window.uni.switchTab = function(options) { + var url = (options && options.url) || ''; + console.log('[uniBridge] switchTab called, url=' + url); + callFlutter('switchTab', url); + if (options && options.success) options.success(); + }; + window.uni.navigateTo = function(options) { + var url = (options && options.url) || ''; + console.log('[uniBridge] navigateTo called, url=' + url); + callFlutter('navigateTo', url); + if (options && options.success) options.success(); + }; + window.uni.reLaunch = function(options) { + var url = (options && options.url) || ''; + console.log('[uniBridge] reLaunch called, url=' + url); + callFlutter('reLaunch', url); + if (options && options.success) options.success(); + }; + console.log('[uniBridge] window.uni injected via console channel'); +})(); +'''; + return script; + } + + @override + Widget build(BuildContext context) { + final h5Url = _buildH5Url(); + talker.debug('ReportWebViewPage loading: $h5Url'); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Text('学生报告'), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)), + onPressed: () => context.canPop() ? context.pop() : context.go('/'), + ), + ), + body: InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(h5Url)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + allowsInlineMediaPlayback: true, + useHybridComposition: true, + ), + // 把 H5 壳子的 console 日志转发到 Flutter talker + onConsoleMessage: (controller, consoleMessage) { + final levelName = consoleMessage.messageLevel + .toString() + .split('.') + .last; + final rawMsg = consoleMessage.message; + final msg = '[H5] [$levelName] $rawMsg'; + if (levelName == 'ERROR') { + talker.error(msg); + } else { + talker.debug(msg); + } + // 解析 H5 通过 console 发来的跳转指令: + // console.log('[FLUTTER_BRIDGE] switchTab', '/pages/tabbar/task/task') + // console.log('[FLUTTER_BRIDGE] navigateTo', '/pages/reportPackage/reportComputation/reportComputation') + // console.log('[FLUTTER_BRIDGE] reLaunch', '/pages/xxx/yyy') + if (rawMsg.startsWith('[FLUTTER_BRIDGE]')) { + final parts = rawMsg + .substring('[FLUTTER_BRIDGE]'.length) + .trim() + .split(' '); + if (parts.length >= 2) { + final action = parts[0]; + final url = parts.sublist(1).join(' '); + final flutterPath = _mapH5Path(url); + talker.debug('[consoleBridge] $action $url → $flutterPath'); + switch (action) { + case 'switchTab': + if (mounted) context.go(flutterPath); + break; + case 'navigateTo': + if (mounted) context.go(flutterPath); + break; + case 'reLaunch': + if (mounted) context.go(flutterPath); + break; + } + } + } + }, + onWebViewCreated: (controller) { + _webController = controller; + _registerJsBridge(controller); + }, + onLoadStop: (controller, url) async { + setState(() => _loadingProgress = 1); + // 注入 window.uni JS Bridge + await controller.evaluateJavascript(source: _uniBridgeScript); + }, + onProgressChanged: (controller, progress) { + setState(() => _loadingProgress = progress / 100.0); + }, + ), + bottomNavigationBar: _loadingProgress < 1.0 + ? LinearProgressIndicator( + value: _loadingProgress, + backgroundColor: Colors.transparent, + valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)), + ) + : null, + ); + } + + /// H5 壳子传的 uni-app 路径 → Flutter 路由路径映射表 + /// + /// H5 壳子里所有 uni.switchTab/navigateTo/reLaunch 都用 uni-app 的路径格式, + /// Flutter Bridge 收到后先过这个表转成 Flutter 自己的路由再跳转。 + static const Map _h5PathMap = { + // switchTab + '/pages/tabbar/task/task': '/', + '/pages/tabbar/recite/recite': '/recite', + '/pages/tabbar/class/class': '/class', + '/pages/tabbar/my/my': '/my', + // navigateTo + '/pages/reportPackage/reportComputation/reportComputation': + '/reportComputation', + // reLaunch + '/pages/reportPackage/studentInfo/studentInfo': '/studentInfo', + '/pages/login/index': '/login', + }; + + /// 把 H5 uni-app 路径转 Flutter 路由,转不了就原样返回 + String _mapH5Path(String h5Path) { + // 去掉 query 参数再匹配 + final pathOnly = h5Path.split('?').first; + return _h5PathMap[pathOnly] ?? h5Path; + } + + // ============================================================ + // JS Bridge:接收 H5 壳子通过 window.uni.switchTab/navigateTo/reLaunch 发起的调用 + // 统一用 uniBridge 一个 handler,参数里带 name 区分 + // ============================================================ + void _registerJsBridge(InAppWebViewController controller) { + // addJavaScriptHandler 在 Chrome Web 平台不支持,跳过即可 + if (kIsWeb) { + talker.debug('[uniBridge] Web平台跳过 registerJsBridge'); + return; + } + controller.addJavaScriptHandler( + handlerName: 'uniBridge', + callback: (args) async { + // 最顶端先打,任何过滤之前 + talker.debug('[uniBridge] 收到 callHandler, args=$args'); + if (args.isEmpty) { + talker.debug('[uniBridge] args 为空,return'); + return; + } + final payload = args.first; + if (payload is! Map) { + talker.debug( + '[uniBridge] payload 不是 Map,return: ${payload.runtimeType}', + ); + return; + } + + final name = payload['name'] as String?; + final options = payload['options'] as Map? ?? {}; + final url = options['url'] as String? ?? ''; + final flutterPath = _mapH5Path(url); + + talker.debug('[uniBridge] $name $url → $flutterPath'); + + switch (name) { + case 'switchTab': + await _handleSwitchTab(flutterPath); + break; + case 'navigateTo': + await _handleNavigateTo(flutterPath); + break; + case 'reLaunch': + await _handleReLaunch(flutterPath); + break; + } + }, + ); + } + + /// H5 壳子调 switchTab:直接跳对应 tab + Future _handleSwitchTab(String flutterPath) async { + if (!mounted) return; + context.go(flutterPath); + } + + /// H5 壳子调 navigateTo:跳到对应 Flutter 页面 + Future _handleNavigateTo(String flutterPath) async { + if (!mounted) return; + context.go(flutterPath); + } + + /// H5 壳子调 reLaunch:重建路由栈跳到对应页面 + Future _handleReLaunch(String flutterPath) async { + if (!mounted) return; + context.go(flutterPath); + } + + @override + void dispose() { + _webController?.dispose(); + super.dispose(); + } +} diff --git a/lib/features/report/student_info_page.dart b/lib/features/report/student_info_page.dart new file mode 100644 index 0000000..28d7d6b --- /dev/null +++ b/lib/features/report/student_info_page.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/auth/api.dart'; +import 'package:new_learn/core/api/report/api.dart'; +import 'package:new_learn/core/api/report/model.dart'; +import 'package:new_learn/core/config/app_config.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; + +/// ============================================================ +/// 【StudentInfoPage】诊断报告欢迎介绍页 —— 对标 Vue studentInfo.vue +/// +/// 路由参数(GoRouteState.extra): +/// context.go('/studentInfo', extra: {'type': 'placementTest'}); +/// - type: placementTest(入门测)| exitTest(出门测) +/// +/// 完整链路:登录成功/全局拦截 → 此页 → H5壳子 → reportComputation(入门测) +/// 或 switchTab 首页(出门测) +/// ============================================================ +class StudentInfoPage extends ConsumerStatefulWidget { + const StudentInfoPage({super.key}); + + @override + ConsumerState createState() => _StudentInfoPageState(); +} + +class _StudentInfoPageState extends ConsumerState { + UserReportType _report = const UserReportType(); + bool _loading = true; + String _reportType = 'placementTest'; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final extra = GoRouterState.of(context).extra as Map? ?? {}; + _reportType = (extra['type'] as String?) ?? 'placementTest'; + _loadReport(); + }); + } + + Future _loadReport() async { + setState(() => _loading = true); + try { + final isExit = _reportType == 'exitTest'; + final apiPath = isExit + ? '/api/v1/UserReport/GetCurrentAfterReport' + : '/api/v1/UserReport/GetCurrentReport'; + talker.debug('studentInfo 调 $apiPath'); + final report = isExit + ? await ReportApi.instance.getCurrentAfterReport() + : await ReportApi.instance.getCurrentReport(); + talker.debug( + 'studentInfo 响应: userName=${report.userName} ' + 'subjectReports.length=${report.subjectReports.length} ' + 'raw=${report.subjectReports.toString()}', + ); + if (mounted) { + setState(() { + _report = report; + _loading = false; + }); + } + } catch (e) { + talker.error('加载报告数据失败', e); + if (mounted) setState(() => _loading = false); + } + } + + /// 按钮点击:subjectReports 为空 → 降级置 false 回首页;否则进 H5 壳子 + Future _onStart() async { + if (_report.subjectReports.isEmpty) { + // 降级:只改本地标记,不调后端 + talker.debug('studentInfo: subjectReports 为空,降级本地置 false'); + if (_reportType == 'exitTest') { + await ref.read(currentUserProvider.notifier).markShowAfterReportFalse(); + } else { + await ref.read(currentUserProvider.notifier).markShowReportFalse(); + } + if (mounted) context.go('/'); + return; + } + // 进 H5 壳子 + context.push('/reportWebView', extra: { + 'type': _reportType, + }); + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(currentUserProvider).value; + final userName = _report.userName.isNotEmpty + ? _report.userName + : (user?.userName ?? ''); + + return Scaffold( + backgroundColor: const Color(0xFFF5F8FF), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)), + onPressed: () => context.canPop() ? context.pop() : context.go('/'), + ), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : SafeArea( + child: Column( + children: [ + // 顶部问候 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Row( + children: [ + const Text( + 'Hi . ', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + Text( + userName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ], + ), + ), + // 科目卡片列表 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(28, 120, 28, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + '诊断科目分析', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: _report.subjectReports.isEmpty + ? const Center( + child: Text( + '暂无数据', + style: TextStyle(color: Color(0xFF687FAD)), + ), + ) + : Wrap( + spacing: 10, + runSpacing: 10, + children: _report.subjectReports + .map((s) => _SubjectCard( + subject: s, + )) + .toList(), + ), + ), + ], + ), + ), + ), + // 底部按钮 + Padding( + padding: const EdgeInsets.fromLTRB(29, 20, 29, 20), + child: SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _onStart, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3D7DF5), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + elevation: 0, + ), + child: const Text( + '开启智能诊断报告', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SubjectCard extends StatelessWidget { + const _SubjectCard({required this.subject}); + final SubjectReportType subject; + + @override + Widget build(BuildContext context) { + return Container( + width: 72, + height: 88, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF5F8FF), Color(0xFFE6EEFF)], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: subject.icon.isNotEmpty + ? Image.network( + subject.icon, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ) + : const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ), + const SizedBox(height: 8), + Text( + subject.subjectName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2B47), + ), + ), + ], + ), + ); + } +}