/// ============================================================ /// 【ReportPage】攻克报告页 /// 1:1 对标 Vue pages/conquerPackage/report/report.vue /// /// 路由 extra: /// - recordId 学习记录 id(同时用于 GetReport/{id}) /// - packId 攻克包 id(跳解析页时作为 totalPackId) /// - from 来源(submit=交卷后进入:隐藏返回、显示小房子) /// ============================================================ library; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/question/exam_answer_card.dart'; import 'package:new_learn/core/utils/question/question_constant.dart'; import 'package:new_learn/core/utils/question/question_parser.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart'; import 'package:new_learn/features/conquerPackage/report/widgets/conquer_poster_popup.dart'; import 'package:new_learn/features/conquerPackage/report/widgets/guide_modal.dart'; import 'package:new_learn/features/conquerPackage/report/widgets/semicircle_progress.dart'; class ReportPage extends ConsumerStatefulWidget { const ReportPage({super.key, this.extra}); final Map? extra; @override ConsumerState createState() => _ReportPageState(); } class _ReportPageState extends ConsumerState { // ===================== 颜色(对标 report.vue / common.scss) ===================== static const Color _bg = Color(0xFFF5F8FA); static const Color _title = Color(0xFF1D2B47); static const Color _primary = Color(0xFF3D7DF5); static const Color _subtitle = Color(0xFF687FAD); static const Color _success = Color(0xFF00CC96); static const Color _error = Color(0xFFFF5252); static const Color _unanswered = Color(0xFF94A5B8); static const Color _failText = Color(0xFFB4C2E0); // ===================== 路由参数 ===================== String _recordId = ''; String _packId = ''; bool _isFromSubmit = false; // ===================== 页面数据 ===================== Map _data = {}; List> _answerCard = []; bool _loading = true; bool _showPoster = false; bool _showGuide = false; // 跳转防连点(对标 navigateToOnce:2 秒节流 + 原子锁 + 1200ms 兜底解锁) bool _navigating = false; int _lastNavigateAt = 0; @override void initState() { super.initState(); final extra = widget.extra ?? const {}; _recordId = '${extra['recordId'] ?? ''}'; _packId = '${extra['packId'] ?? ''}'; _isFromSubmit = extra['from'] == 'submit'; WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } // ===================== 数据加载(对标 GetSubmitReportFn) ===================== Future _load() async { setState(() => _loading = true); try { final res = await ClassExercisesApi.instance.getSubmitReport(_recordId); final user = ref.read(currentUserProvider).value; final userName = (user?.userName.isNotEmpty ?? false) ? user!.userName : '我'; final userAvatar = user?.avatar ?? ''; final data = Map.from(res.raw); // 后端数值字段可能返回 String,统一规范为 num,避免 build 中 as num 崩溃 final accuracyRate = num.tryParse('${data['accuracyRate'] ?? 0}') ?? 0; setState(() { _data = { ...data, 'userName': userName, 'userAvatar': userAvatar, 'accuracyRate': accuracyRate, }; }); // questions 解析 / 答题卡构建单独兜底:单题异常不影响头部与统计 final questions = res.questions; if (questions.isNotEmpty) { try { final subjectType = getSubjectTypeById(data['baseSubjectId']) ?? 0; final parsed = questions.map((item) { item['iSFilllankItemUpload'] = subjectType; return parseQuestionData(item, mergeOriginalChilds: true); }).toList(); var questionsData = appendQuestionSort(parsed); questionsData = appendExamQuestionChildOptionStatus(questionsData, data['baseSubjectId']); final card = buildExamAnswerCard(questionsData); setState(() => _answerCard = card); } catch (e) { debugPrint('GetSubmitReport build answerCard error: $e'); } } // status 1 已攻克 / 2 未攻克 → 弹海报 final status = _di('status'); if (status == 1 || status == 2) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _showPoster = true); }); } } catch (e) { _toast('获取报告失败,请稍后重试'); } finally { if (mounted) setState(() => _loading = false); } } int _di(String key) => int.tryParse('${_data[key] ?? 0}') ?? 0; num get _accuracyRate => num.tryParse('${_data['accuracyRate'] ?? 0}') ?? 0; bool get _hasHandouts { final h = _data['handouts']; return h is List && h.isNotEmpty; } // 回答状态(未回答=0, 判题中=1, 正确=10, 错误=11, 部分错误=12) Color _statusColor(dynamic status) { final s = int.tryParse('$status') ?? 0; if (s == 10) return _success; if (s == 11 || s == 12 || s == 1) return _error; return _unanswered; } Color _statusBg(dynamic status) { final s = int.tryParse('$status') ?? 0; if (s == 10) return const Color(0xFFE6FAF4); if (s == 11 || s == 12 || s == 1) return const Color(0xFFFFF2F2); return const Color(0xFFF5F7FA); } // ===================== 跳转(对标 navigateToOnce) ===================== void _navigateToOnce(String location, Map extra) { final now = DateTime.now().millisecondsSinceEpoch; if (now - _lastNavigateAt < 2000) return; if (_navigating) return; _lastNavigateAt = now; _navigating = true; Future.delayed(const Duration(milliseconds: 1200), () { _navigating = false; }); context.push(location, extra: extra).whenComplete(() { _navigating = false; }); } /// 查看讲义 void _getLectureNotes() { final id = '${_data['id'] ?? ''}'; _navigateToOnce( '/conquerPackage/beforeDuringClass', { 'status': '1', 'packId': id, 'recordId': id, 'type': 'view', }, ); } /// 全部解析 / 点答题卡题号(带 targetSort) void _handleShowAnswerCard([Map? question]) { final targetSort = int.tryParse('${question?['sort'] ?? 0}') ?? 0; _navigateToOnce( '/conquerPackage/classExercises', { 'courseId': '${_data['courseId'] ?? ''}', 'packId': '${_data['id'] ?? ''}', 'type': 'view', 'totalPackId': _packId, if (targetSort > 0) 'targetSort': targetSort, }, ); } /// 错题解析 void _handleWrongQuestionAnalysis() { _navigateToOnce( '/conquerPackage/classExercises', { 'courseId': '${_data['courseId'] ?? ''}', 'packId': '${_data['id'] ?? ''}', 'type': 'view', 'totalPackId': _packId, 'isWrong': true, }, ); } /// 海报关闭 → 未看过引导时延迟 500ms 弹引导 void _handlePosterClose() { setState(() => _showPoster = false); final isShowGuide = ref.read(currentUserProvider).value?.isShowGuide; if (isShowGuide == false) { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) setState(() => _showGuide = true); }); } } void _toast(String text) { if (!mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text(text), duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), ); } // ===================== Build ===================== @override Widget build(BuildContext context) { final pad = isPadDevice(context); final correct = _di('correctQuestionCount'); final total = _di('totalQuestionCount'); final allCorrect = correct == total && total > 0; return Scaffold( body: Stack( children: [ // HeaderBac 顶部背景图(固定屏幕顶部,与 classExercises 页一致) Positioned( top: 0, left: 0, right: 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png', ), fit: BoxFit.fitWidth, width: double.infinity, placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), // 内容层:非定位 Column + Expanded,让滚动区严格撑满整屏 // (与 class_exercises_page 相同的已验证骨架) SafeArea( bottom: false, child: Column( children: [ _buildNavBar(), Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildReportBac(pad), _buildAnswerCard(), // 底部固定栏留白 220rpx SizedBox(height: 110.h), ], ), ), ), ], ), ), _buildFooter(allCorrect: allCorrect), ConquerPosterPopup( show: _showPoster, status: _di('status'), name: '${_data['name'] ?? ''}', accuracyRate: _accuracyRate, overcomeTimeSecond: _di('overcomeTimeSecond'), userName: '${_data['userName'] ?? '我'}', userAvatar: '${_data['userAvatar'] ?? ''}', onClose: _handlePosterClose, ), GuideModal( show: _showGuide, onClose: () => setState(() => _showGuide = false), ), if (_loading) _buildLoadingMask(), ], ), ); } // ---------- 加载遮罩(对标 uni.showLoading 加载中...) ---------- Widget _buildLoadingMask() { return const Positioned.fill( child: ColoredBox( color: Color(0x33000000), child: Center( child: Card( color: Color(0xCC000000), child: Padding( padding: EdgeInsets.symmetric(horizontal: 24, vertical: 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator( strokeWidth: 3, color: Colors.white, ), SizedBox(height: 12), Text( '加载中...', style: TextStyle(color: Colors.white, fontSize: 14), ), ], ), ), ), ), ), ); } // ===================== 滚动内容(HeaderBac + NavBar + 卡片) ===================== // 背景图 / 滚动体 / NavBar 已在 build 的外层全屏 Stack 中分层,见 build()。 // ---------- NavBar(对标 NavBar.vue:居中标题 + 返回/小房子) ---------- // 状态栏高度由外层 SafeArea 处理,这里只绘制 44px 的导航条。 Widget _buildNavBar() { return SizedBox( width: double.infinity, height: 44.h, child: Stack( children: [ Center( child: Padding( padding: EdgeInsets.only(left: 10.w), child: Text( '攻克报告', maxLines: 1, style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w400, color: Colors.black, ), ), ), ), Positioned( left: 12.w, top: 0, bottom: 0, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (!_isFromSubmit) _navIcon( '/StaticResource/2026-04-01/return.png', onTap: _handleBack, ), if (_isFromSubmit) _navIcon( '/StaticResource/2026-06-02/homesa1780391437267.png', onTap: () => context.go('/'), ), ], ), ), ], ), ); } Widget _navIcon(String path, {required VoidCallback onTap}) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Padding( padding: EdgeInsets.symmetric(horizontal: 2.w, vertical: 10.h), child: CachedNetworkImage( imageUrl: ImgUrl.build(path), width: 24.w, height: 24.h, fit: BoxFit.contain, placeholder: (_, _) => Icon( Icons.arrow_back_ios, size: 18.w, color: const Color(0xCC000000), ), errorWidget: (_, _, _) => Icon( Icons.home_outlined, size: 20.w, color: const Color(0xCC000000), ), ), ), ); } void _handleBack() { if (context.canPop()) { context.pop(); } else { context.go('/'); } } // ===================== report-bac:头部 + 正确率/统计白卡 ===================== Widget _buildReportBac(bool pad) { final status = _di('status'); return Container( margin: EdgeInsets.fromLTRB(14.w, 30.h, 14.w, 0), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFAFEFF), Color(0xFFEDF7FF)], ), border: Border.all(color: Colors.white, width: 2), borderRadius: BorderRadius.circular(20), ), child: Stack( clipBehavior: Clip.none, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // head Padding( padding: EdgeInsets.fromLTRB( pad ? 29.w : 16.w, 15.h, pad ? 29.w : 16.w, 0), child: SizedBox( width: double.infinity, child: LayoutBuilder( builder: (_, constraints) => SizedBox( // .report-head view { width: 55% } width: constraints.maxWidth * 0.55, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (status == 1) _buildHeadTips( icon: '/StaticResource/2026-04-07/conquer-success-icon.png', text: '恭喜你,已攻克!', color: _primary, ), if (status == 2) _buildHeadTips( icon: '/StaticResource/2026-04-07/conquer-fail-icon.png', text: '本次未攻克', color: _failText, ), Padding( padding: EdgeInsets.only(top: 7.5.h), child: Text( '${_data['name'] ?? ''}', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, color: _title, ), ), ), ], ), ), ), ), ), // report-main(margin-t-50) Padding( padding: EdgeInsets.only(top: 25.h), child: _buildMainCard(), ), ], ), // 右上勋章 260rpx,top -100rpx if (status == 1 || status == 2) Positioned( top: -50.h, right: pad ? 20.w : 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( status == 1 ? '/StaticResource/2026-04-07/conquer-success.png' : '/StaticResource/2026-04-07/conquer-fail.png', ), width: 130.w, height: 130.h, fit: BoxFit.contain, ), ), ], ), ); } Widget _buildHeadTips({ required String icon, required String text, required Color color, }) { return Row( children: [ CachedNetworkImage( imageUrl: ImgUrl.build(icon), width: 24.w, height: 24.h, fit: BoxFit.contain, ), SizedBox(width: 10.w), Flexible( child: Text( text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14.sp, color: color), ), ), ], ); } Widget _buildMainCard() { return Container( width: double.infinity, padding: EdgeInsets.all(14.w), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), ), child: Column( children: [ // 半圆环(padding-b-50) Padding( padding: EdgeInsets.only(bottom: 25.h), child: SemicircleProgress( percent: _accuracyRate, ), ), _buildStatistics(), ], ), ); } Widget _buildStatistics() { return Container( padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 20.w), decoration: BoxDecoration( color: const Color(0xFFFAFBFD), border: Border.all(color: const Color(0xFFE3ECFF), width: 1.w), borderRadius: BorderRadius.circular(10.r), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _statItem('${_di('totalQuestionCount')}', '总计', _title), _statItem('${_di('correctQuestionCount')}', '答对', _success), _statItem('${_di('incorrectQuestionCount')}', '答错', _error), _statItem('${_di('unansweredQuestionCount')}', '未答', _title), _statItem(formatDuration(_di('overcomeTimeSecond')), '用时', _title), ], ), ); } Widget _statItem(String num, String label, Color color) { return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.only(bottom: 4.h), child: Text( num, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: color, ), ), ), Text( label, style: TextStyle(fontSize: 12.sp, color: _subtitle), ), ], ); } // ===================== 答题卡卡 ===================== Widget _buildAnswerCard() { return Container( width: double.infinity, margin: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 0), padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 20.h), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10.r), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildCardHead(), for (var i = 0; i < _answerCard.length; i++) _buildGroup(_answerCard[i], i), ], ), ); } Widget _buildCardHead() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-08/conquer-card-icon.png', ), width: 18.w, height: 18.h, fit: BoxFit.contain, ), SizedBox(width: 5.w), Container( width: 59.5.w, height: 24.h, alignment: Alignment.centerLeft, decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider( ImgUrl.build( '/StaticResource/2026-04-01/title-bacs.png', ), ), fit: BoxFit.fitWidth, alignment: Alignment.bottomCenter, ), ), child: Text( '答题卡', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w600, color: _title, ), ), ), ], ), Row( children: [ _legend(_success, '答对'), _legend(_error, '答错'), _legend(_unanswered, '未答'), ], ), ], ); } Widget _legend(Color color, String label) { return Padding( padding: EdgeInsets.only(left: 10.w), child: Row( children: [ Container( width: 6.w, height: 6.w, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), SizedBox(width: 5.w), Text( label, style: TextStyle(fontSize: 12.sp, color: _subtitle), ), ], ), ); } Widget _buildGroup(Map group, int index) { final questions = (group['questions'] as List?) ?? const []; return Padding( padding: EdgeInsets.only(top: 20.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(bottom: 12.h), child: Text( '${group['name'] ?? ''}', style: TextStyle(fontSize: 12.sp, color: _subtitle), ), ), Wrap( spacing: 10.w, runSpacing: 10.w, children: [ for (final q in questions) _buildQuestionItem(Map.from(q as Map)), ], ), ], ), ); } Widget _buildQuestionItem(Map question) { final status = question['status']; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _handleShowAnswerCard(question), child: Container( width: 44.w, height: 36.h, alignment: Alignment.center, decoration: BoxDecoration( color: _statusBg(status), borderRadius: BorderRadius.circular(6.r), ), child: Text( '${question['sort'] ?? ''}', style: TextStyle(fontSize: 14.sp, color: _statusColor(status)), ), ), ); } // ===================== 底部固定按钮栏 ===================== Widget _buildFooter({required bool allCorrect}) { return Positioned( left: 0, right: 0, bottom: 0, child: Container( decoration: BoxDecoration( color: Colors.white, border: Border( top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w), ), ), padding: EdgeInsets.fromLTRB( 14.w, 12.h, 14.w, 12.h + MediaQuery.of(context).padding.bottom, ), child: Row( children: [ if (_hasHandouts) ...[ _buildLectureEntry(), SizedBox(width: 10.w), ], Expanded( flex: allCorrect ? (_hasHandouts ? 80 : 100) : (_hasHandouts ? 40 : 48), child: _footerButton( text: '全部解析', primary: false, onTap: () => _handleShowAnswerCard(), ), ), if (!allCorrect) ...[ SizedBox(width: 14.w), Expanded( flex: _hasHandouts ? 40 : 48, child: _footerButton( text: '错题解析', primary: true, onTap: _handleWrongQuestionAnalysis, ), ), ], ], ), ), ); } Widget _buildLectureEntry() { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _getLectureNotes, child: Column( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'), width: 24.w, height: 24.h, fit: BoxFit.contain, ), SizedBox(height: 5.h), Text( '查看讲义', style: TextStyle(fontSize: 10.sp, color: _title), ), ], ), ); } /// default-button4(白底蓝边)/ default-button1(蓝渐变 + 投影) Widget _footerButton({ required String text, required bool primary, required VoidCallback onTap, }) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( height: 46.h, alignment: Alignment.center, decoration: BoxDecoration( color: primary ? null : Colors.white, gradient: primary ? const LinearGradient( colors: [Color(0xFF3373FF), Color(0xFF3D7DF5)], ) : null, borderRadius: BorderRadius.circular(50.r), border: primary ? null : Border.all(color: _primary, width: 1.w), boxShadow: primary ? const [ BoxShadow( color: Color(0x4D3D7DF5), offset: Offset(0, 7.5), blurRadius: 15, ), ] : null, ), child: Text( text, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w400, color: primary ? Colors.white : _primary, ), ), ), ); } }