Files
App.K12.Student.Miniapp_Flu…/lib/features/conquerPackage/report/report_page.dart
T
2026-09-28 20:12:34 +08:00

886 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 【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<String, dynamic>? extra;
@override
ConsumerState<ReportPage> createState() => _ReportPageState();
}
class _ReportPageState extends ConsumerState<ReportPage> {
// ===================== 颜色(对标 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<String, dynamic> _data = <String, dynamic>{};
List<Map<String, dynamic>> _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 <String, dynamic>{};
_recordId = '${extra['recordId'] ?? ''}';
_packId = '${extra['packId'] ?? ''}';
_isFromSubmit = extra['from'] == 'submit';
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
// ===================== 数据加载(对标 GetSubmitReportFn) =====================
Future<void> _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<String, dynamic>.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<String, dynamic> 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>.from(q as Map)),
],
),
],
),
);
}
Widget _buildQuestionItem(Map<String, dynamic> 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,
),
),
),
);
}
}