/// ============================================================ /// 【ExerciseQuestionView】课后练习单题页 /// (1:1 迁移 Vue pages/conquerPackage/classExercises/classExercisesPage.vue) /// /// 结构(与 Vue 模板一致): /// source → 题型标签 → 题干合并音视频 → 题干(含 #subquestion-N 锚点) /// → 主体(客观选项 / 填空输入或图片作答 / 主观图片作答) /// → view 态 childs 解析列表 → 大题解析盒 → 底部笔记 Upload /// → 末尾 80.h + panelHeight 白色占位 /// /// 交互(与 Vue emit / defineExpose 同名同义): /// - onSelectAnswer(payload) → selectAnswer /// - onUpdateAnswer(String) → UpdateAnswer /// - onNotesChange(items) → handleNotesChange /// - onSubQuestionChange(payload) → subQuestionChange /// - onStemLinkTap(index) → 题干锚点点击,由父级驱动复合题面板 /// - `GlobalKey` 暴露: /// UploadLectureNotes() / getImageList(type) / /// setActiveSubIndex(i) / pauseMedia() / scrollToOffset() /// /// 说明: /// - 父级应以 ValueKey(data.id) 挂载,切题整体重建; /// - 锚点滚动:#subquestion-N 点击后 onTapUrl 返回 false,交由 /// flutter_widget_from_html 内置 AnchorRegistry 滚动(等价 Vue /// emitScrollToTop → outer scroll-view 定位);面板 tab 切换等 /// 外部回灌走 scrollToAnchor(编程式标记位,不再回调父级); /// - isActive/isAdjacent 变 false 暂停全部音视频(递归 visitChildElements)。 /// ============================================================ library; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:new_learn/core/components/audio_player_card.dart'; import 'package:new_learn/core/components/media_pause_scope.dart'; import 'package:new_learn/core/components/rich_text_content.dart'; import 'package:new_learn/core/components/upload.dart'; import 'package:new_learn/core/components/video_player_view.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/question/question_parser.dart'; import 'package:new_learn/core/utils/question/tag_color.dart'; import 'models/question_data_adapter.dart'; import 'models/question_model.dart'; import 'widgets/answer_view.dart'; import 'widgets/exercise_styles.dart'; import 'widgets/obj_options.dart'; /// 对应 Vue props.typeMode enum ExerciseQuestionMode { edit, view } /// 对应 emit('subQuestionChange', {subIndex, sort}) class SubQuestionChangePayload { const SubQuestionChangePayload({required this.subIndex, this.sort = 0}); final int subIndex; final dynamic sort; } typedef QuestionSelectCallback = void Function(Map payload); typedef QuestionUpdateAnswerCallback = void Function(String value); typedef QuestionNotesCallback = void Function(List items); typedef QuestionSubChangeCallback = void Function(SubQuestionChangePayload payload); typedef QuestionStemLinkCallback = void Function(int subIndex); class ExerciseQuestionView extends StatefulWidget { const ExerciseQuestionView({ super.key, required this.data, this.isActive = false, this.isAdjacent = false, this.mode = ExerciseQuestionMode.edit, this.activeSubIndex = 0, required this.panelHeight, this.onSelectAnswer, this.onUpdateAnswer, this.onNotesChange, this.onSubQuestionChange, this.onStemLinkTap, }); /// 题目数据(与 questionsList 元素同一 Map 引用) final QuestionModel data; final bool isActive; final bool isAdjacent; final ExerciseQuestionMode mode; /// 当前激活子题下标(外部回灌) final int activeSubIndex; /// 复合题浮层当前高度(逻辑像素),用于末尾占位。 /// 用 ValueListenable 接收:拖拽面板时只重建占位盒,不整页重建。 final ValueListenable panelHeight; final QuestionSelectCallback? onSelectAnswer; final QuestionUpdateAnswerCallback? onUpdateAnswer; final QuestionNotesCallback? onNotesChange; final QuestionSubChangeCallback? onSubQuestionChange; final QuestionStemLinkCallback? onStemLinkTap; @override ExerciseQuestionViewState createState() => ExerciseQuestionViewState(); } class ExerciseQuestionViewState extends State { final ScrollController _scrollController = ScrollController(); /// 本题媒体暂停注册表:子树内播放器自动注册,切页时直接暂停, /// 不再运行时递归 visitChildElements(避免 element 树更新期断言) final MediaPauseRegistry _mediaRegistry = MediaPauseRegistry(); /// 题干 HtmlWidget(锚点滚动用) final GlobalKey _stemHtmlKey = GlobalKey(); /// 图片作答 Upload(uploadAnswerRef) final GlobalKey _answerUploadKey = GlobalKey(); /// 笔记 Upload(uploadRef) final GlobalKey _notesKey = GlobalKey(); late int _activeSubIndex = widget.activeSubIndex; /// 图片作答缓存(惰性初始化,保持引用稳定,避免 Upload 被重置) List? _answerItems; /// 笔记图片缓存 List? _notesItems; TextEditingController? _blankController; /// 本次锚点切换是否来自题干点击(点击已由 fwfh 滚动,外部回灌跳过) int? _tapDrivenIndex; /// 编程式锚点滚动标记(scrollToAnchor 期间不要再驱动面板) bool _programmaticAnchor = false; bool get _isView => widget.mode == ExerciseQuestionMode.view; bool get _isEdit => widget.mode == ExerciseQuestionMode.edit; // ===================== 生命周期 ===================== @override void initState() { super.initState(); // 对标 watch(activeSubIndex, immediate: true):挂载即尝试定位锚点 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _scrollToAnchorExternal(widget.activeSubIndex); }); } @override void didUpdateWidget(covariant ExerciseQuestionView oldWidget) { super.didUpdateWidget(oldWidget); // 正常父级以 ValueKey(data.id) 重建;兜底处理 if (widget.data.id != oldWidget.data.id) { _activeSubIndex = 0; _answerItems = null; _notesItems = null; pauseMedia(); } // 对标 watch(isActive) / watch(isAdjacent) if ((widget.isActive != oldWidget.isActive && !widget.isActive) || (widget.isAdjacent != oldWidget.isAdjacent && !widget.isAdjacent)) { pauseMedia(); } // 对标 watch(() => props.activeSubIndex) if (widget.activeSubIndex != _activeSubIndex && widget.activeSubIndex != oldWidget.activeSubIndex) { final ext = widget.activeSubIndex; if (ext == _tapDrivenIndex) { // 点击触发的回灌,fwfh 已在点击时滚动 _tapDrivenIndex = null; } else { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _scrollToAnchorExternal(ext); }); } setState(() => _activeSubIndex = ext); } } @override void dispose() { _scrollController.dispose(); _blankController?.dispose(); super.dispose(); } // ===================== 对外暴露(对标 defineExpose) ===================== /// 触发笔记选图(对标 UploadLectureNotes → uploadRef.chooseImage) Future uploadLectureNotes() async { return _notesKey.currentState?.chooseImage(); } /// 取图片列表(对标 getImageList) /// - type == 'uploadAnswerRef':作答图,兜底 safeImageAnswerList(userAnswer) /// - 其它:笔记图,兜底 safeImageAnswerList(notes) List getImageList(String type) { if (type == 'uploadAnswerRef') { final imageList = _answerUploadKey.currentState?.getImageList(); if (imageList != null && imageList.isNotEmpty) return imageList; return QuestionDataAdapter.safeImageAnswerList(widget.data.userAnswer); } final imageList = _notesKey.currentState?.getImageList(); if (imageList != null && imageList.isNotEmpty) return imageList; return QuestionDataAdapter.safeImageAnswerList(widget.data.m['notes']); } /// 对标 setActiveSubIndex:clamp + emit subQuestionChange void setActiveSubIndex(int subIndex) { final total = widget.data.subQuestions.length; final next = total > 0 ? subIndex.clamp(0, total - 1) : 0; if (next == _activeSubIndex) return; setState(() => _activeSubIndex = next); final sort = widget.data.subAt(next)?.sort ?? 0; widget.onSubQuestionChange ?.call(SubQuestionChangePayload(subIndex: next, sort: sort)); } /// 暂停视图内全部音视频(对标 pauseMedia)。 /// 直接调注册表:播放器挂载/卸载已自动维护成员, /// 无需运行时递归遍历 element 树,build/翻页动画阶段调用同样安全。 void pauseMedia() { _mediaRegistry.pauseAll(); } /// 父级直接驱动滚动(对标 emit scrollToOffset → outer scroll-view) void scrollToOffset(double offset) { if (!_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; _scrollController.jumpTo(offset.clamp(0, max)); } // ===================== 锚点滚动 ===================== /// 题干链接点击:驱动面板 + 交回 fwfh 滚动锚点(return false) Future _onStemTap(String url) async { if (url.startsWith('#subquestion-')) { final idx = int.tryParse(url.replaceFirst('#subquestion-', '')); if (idx != null && idx >= 0) { _tapDrivenIndex = idx; widget.onStemLinkTap?.call(idx); // false:fwfh 继续执行 AnchorRegistry.ensureVisible return false; } } // 其余链接一律拦截(RichTextContent 默认行为) return true; } /// 外部回灌(面板 tab / 切题恢复):只滚动,不再回调 onStemLinkTap void _scrollToAnchorExternal(int index, {int retries = 4}) { bool done = false; void run(int left) async { if (!mounted || done) return; _programmaticAnchor = true; bool ok = false; try { ok = await _stemHtmlKey.currentState ?.scrollToAnchor('subquestion-$index') ?? false; } finally { _programmaticAnchor = false; } if (ok) { done = true; return; } if (left > 0 && mounted) { Future.delayed(const Duration(milliseconds: 120), () { if (mounted) run(left - 1); }); } } run(retries); } // ===================== 作答回调 ===================== /// 对标 selectOption(父级写 current.userAnswer = payload.userAnswer) void _selectOption(Map payload) { if (!_isEdit) return; widget.onSelectAnswer?.call(payload); } /// 对标 onInputBlank:写 data.userAnswer + 第一个 child,emit 纯文本 void _onInputBlank(String value) { if (!_isEdit) return; widget.data.userAnswer = value; final childs = widget.data.childs; if (childs.isNotEmpty) { childs.first.userAnswer = value; } widget.onUpdateAnswer?.call(value); } /// 对标 handleUploadChange:emit UpdateAnswer(JSON.stringify(e)) void _onAnswerUploadChanged(List items) { _answerItems = List.from(items); widget.onUpdateAnswer ?.call(QuestionDataAdapter.uploadItemsToJson(items)); } /// 对标 handleNotesChange void _onNotesChanged(List items) { _notesItems = List.from(items); widget.onNotesChange?.call(items); } List get _answerUploadItems { return _answerItems ??= QuestionDataAdapter.toUploadItems( QuestionDataAdapter.parseImageAnswerList(widget.data.userAnswer), ); } List get _notesUploadItems { return _notesItems ??= QuestionDataAdapter.toUploadItems( widget.data.notes, ); } // ===================== Build ===================== @override Widget build(BuildContext context) { // 题干渲染数据(对标 stemRenderData / mergedAudios / mergedVideos) final stemMedia = QuestionDataAdapter.buildStemMedia(widget.data, _activeSubIndex); final children = [ // ---- source ---- if (widget.data.source.isNotEmpty) Padding( padding: EdgeInsets.only(bottom: 10.h), child: Text( widget.data.source, style: TextStyle(fontSize: 14.sp, color: ExerciseColors.subtitle), ), ), // ---- 题型标签 ---- Padding( padding: EdgeInsets.only(bottom: 10.h), child: _buildTypeLabel(), ), // ---- 题干合并音视频 ---- for (final audio in stemMedia.audios) Padding( padding: EdgeInsets.symmetric(vertical: 10.h), child: AudioPlayerCard( url: '${audio['audioUrl'] ?? audio['url'] ?? ''}', ), ), for (final video in stemMedia.videos) Padding( padding: EdgeInsets.symmetric(vertical: 10.h), child: VideoPlayerView( url: '${video['videoUrl'] ?? video['url'] ?? ''}', ), ), // ---- 题干(line-height 58rpx,margin-b 30rpx)---- Padding( padding: EdgeInsets.only(bottom: 15.h), child: RichTextContent( htmlWidgetKey: _stemHtmlKey, html: stemMedia.content, textStyle: TextStyle( fontSize: 16.sp, color: const Color(0xFF333333), height: 58 / 32, ), onTapUrl: (url) async { // 编程式滚动期间:锚点链接不拦截(让 fwfh 滚动),也不驱动面板 if (_programmaticAnchor && url.startsWith('#subquestion-')) { return false; } return _onStemTap(url); }, ), ), ]; // ---- 主体(v-if="!data.hasOptionChildrenQuestion")---- if (!widget.data.hasOptionChildrenQuestion) { _buildBody(children); } // ---- childs 解析列表(view,且非 hasOptionChildrenQuestion)---- if (!widget.data.hasOptionChildrenQuestion && widget.data.childs.isNotEmpty && _isView) { _buildChildsAnalysis(children); } // ---- 大题解析盒(仅 view)---- if (_isView) { _appendMainAnalysis(children); } // ---- 底部笔记 Upload ---- children.add(Padding( padding: EdgeInsets.only(top: isPadDevice(context) ? 40.h : 30.h), child: Upload( key: _notesKey, type: UploadType.questions, ids: widget.data.id, mode: _isEdit ? UploadMode.edit : UploadMode.view, initialImageList: _notesUploadItems, onChanged: _onNotesChanged, ), )); return MediaPauseScope( registry: _mediaRegistry, child: ClipRRect( borderRadius: BorderRadius.vertical(top: Radius.circular(14.r)), child: ColoredBox( color: Colors.white, child: SingleChildScrollView( controller: _scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 对标 .classExercises-main(白底、上圆角 28rpx、padding 40rpx 28rpx) Container( width: double.infinity, color: Colors.white, padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 20.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: children, ), ), // 对标末尾占位 calc(160rpx + panelHeight px)。 // 仅占位盒随面板高度重建,拖拽不触发整页重建。 ValueListenableBuilder( valueListenable: widget.panelHeight, builder: (_, h, __) => Container( width: double.infinity, height: 80.h + h, color: Colors.white, ), ), ], ), ), ), ), ); } /// 题型标签(对标 .classExercises-main-label + getTagColor) Widget _buildTypeLabel() { final gradient = getTagColor(widget.data.questionTypeName); return Container( height: 21.h, padding: EdgeInsets.symmetric(horizontal: 6.w), decoration: BoxDecoration( gradient: gradient.gradient, borderRadius: BorderRadius.circular(6.r), ), child: Center( widthFactor: 1, child: Text( widget.data.questionTypeName, style: TextStyle( fontSize: 12.sp, color: Colors.white, height: 1, ), ), ), ); } // ===================== 主体分支 ===================== void _buildBody(List children) { final data = widget.data; // 分支 1:客观题(options 非空) if (data.options.isNotEmpty) { if (_isEdit) { children.add(ObjOptions( options: data.options, questionType: data.questionTypes, mode: ObjOptionMode.edit, answer: data.userAnswer, onChanged: _selectOption, )); } else { children.add(ObjOptions( options: data.options, questionType: data.questionTypes, mode: ObjOptionMode.view, answerData: ObjOptionAnswerData( correctAnswer: data.correctAnswer, myAnswer: data.userAnswer, ), onChanged: _selectOption, )); children.add(Padding( padding: EdgeInsets.only(bottom: 10.h), child: ObjectiveAnswerView( answerStatus: data.answerStatus, userAnswer: data.userAnswer, correctAnswer: data.correctAnswer, ), )); } return; } // 分支 2:填空题 if (data.questionTypes == 'tiankongti') { _buildBlankBody(children); return; } // 分支 3:其他主观题(问答题等,图片作答) _buildSubjectiveBody(children); } /// 填空题主体(对标 v-else-if="questionTypes == 'tiankongti'") void _buildBlankBody(List children) { final data = widget.data; if (_isView) { children.add(_buildStatusStrip(data)); } if (data.isFillblankItemUpload == 1) { // 图片作答填空题(注意:单题页 maxCount = 10) children.add(Padding( padding: EdgeInsets.only(top: 10.h), child: Upload( key: _answerUploadKey, title: '上传图片作答', maxCount: 10, type: UploadType.aiGrading, ids: data.id, mode: _isEdit ? UploadMode.edit : UploadMode.view, initialImageList: _answerUploadItems, onChanged: _onAnswerUploadChanged, ), )); if (_isView) { _appendWrongReasonAdvice(children); } } else if (_isEdit) { children.add(_buildBlankTextarea()); } if (_isView) { if (data.isFillblankItemUpload != 1) { children.add(Padding( padding: EdgeInsets.only(top: 10.h), child: _buildAnswerTitle('你的答案'), )); children.add(_buildAnswerContent( data.effectiveAnswer, const Color(0xFF495A7A), )); } if (data.hasOptionChildrenQuestion || data.childs.isEmpty) { children.add(_buildAnswerTitle('正确答案')); children.add(_buildAnswerContent( data.correctAnswer.isNotEmpty ? data.correctAnswer : data.answer, ExerciseColors.correct, )); } } } /// 主观题主体(问答题等) void _buildSubjectiveBody(List children) { final data = widget.data; if (_isView) { children.add(_buildStatusStrip(data)); } children.add(Padding( padding: EdgeInsets.only(top: isPadDevice(context) ? 40.h : 30.h), child: Upload( key: _answerUploadKey, title: '上传图片作答', maxCount: 10, type: UploadType.aiGrading, ids: data.id, mode: _isEdit ? UploadMode.edit : UploadMode.view, initialImageList: _answerUploadItems, onChanged: _onAnswerUploadChanged, ), )); if (_isView) { _appendWrongReasonAdvice(children); if (data.hasOptionChildrenQuestion || data.childs.isEmpty) { children.add(_buildAnswerTitle('正确答案')); children.add(_buildAnswerContent( data.correctAnswer.isNotEmpty ? data.correctAnswer : data.answer, ExerciseColors.correct, )); } } } /// 错误原因 / 改进建议(仅 view,answerStatus 11/12) void _appendWrongReasonAdvice(List children) { final data = widget.data; final status = data.answerStatus; if (status != 11 && status != 12) return; final reason = data.wrongReason; if (reason != null && reason.isNotEmpty) { children.add(Padding( padding: EdgeInsets.only(top: 10.h), child: _buildAnswerTitle('错误原因'), )); children.add(_buildPlainContent(reason, const Color(0xFF495A7A))); } final advice = data.wrongAdvice; if (advice != null && advice.isNotEmpty) { children.add(Padding( padding: EdgeInsets.only(top: 10.h), child: _buildAnswerTitle('改进建议'), )); children.add(_buildPlainContent(advice, const Color(0xFF495A7A))); } } /// 填空题输入框(对标 textarea.auto-height + .input-text) Widget _buildBlankTextarea() { final controller = _blankController ??= TextEditingController( text: QuestionDataAdapter.singleBlankInputValue(widget.data), ); return Container( width: double.infinity, margin: EdgeInsets.only(bottom: 10.h), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h), decoration: BoxDecoration( color: ExerciseColors.bg, borderRadius: BorderRadius.circular(12.r), border: Border.all(color: ExerciseColors.border, width: 1.w), ), child: TextField( controller: controller, minLines: 1, maxLines: null, style: TextStyle( fontSize: 16.sp, color: const Color(0xFF333333), ), onChanged: _onInputBlank, decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入您的答案...', hintStyle: TextStyle( fontSize: 16.sp, color: const Color(0xFF94A5B8), ), ), ), ); } // ===================== childs 解析列表 ===================== void _buildChildsAnalysis(List children) { final data = widget.data; final childs = data.childs; for (var i = 0; i < childs.length; i++) { final child = childs[i]; children.add(_buildAnswerTitle( '第${i + 1}${data.questionTypes == 'tiankongti' ? '空' : '题'}', fontSize: 17.sp, )); if (child.stem.isNotEmpty) { children.add(Padding( padding: EdgeInsets.only(bottom: 5.h), child: RichTextContent( html: child.stem, textStyle: TextStyle( fontSize: 16.sp, color: const Color(0xFF333333), ), ), )); } children.add(_buildAnswerTitle('正确答案')); children.add(_buildAnswerContent( child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer, ExerciseColors.correct, )); final analysis = _analysisText(child); final videos = child.videos; final analysisVideo = child.answerAnalysisVideo; final hasVideo = videos.isNotEmpty && '${videos.first['videoUrl'] ?? videos.first['url'] ?? ''}' .isNotEmpty; final hasAnalysisVideo = '${analysisVideo?['videoUrl'] ?? ''}'.isNotEmpty; if (analysis.isNotEmpty || hasVideo || hasAnalysisVideo) { final content = [ if (analysis.isNotEmpty) _buildAnalysisBody(analysis), if (hasVideo) for (final video in videos) Padding( padding: EdgeInsets.only(top: 10.h), child: VideoPlayerView( url: '${video['videoUrl'] ?? video['url'] ?? ''}', ), ), if (hasAnalysisVideo) Padding( padding: EdgeInsets.only(top: 10.h), child: VideoPlayerView( url: '${analysisVideo?['videoUrl'] ?? ''}', ), ), ]; children.add(_buildAnalysisBox(content, minHeight: true)); } } } // ===================== 大题解析盒 ===================== void _appendMainAnalysis(List children) { final data = widget.data; final analysis = _analysisText(data); final audios = data.audios; final videos = data.videos; final analysisVideo = data.answerAnalysisVideo; final hasVideo = videos.isNotEmpty && '${videos.first['videoUrl'] ?? videos.first['url'] ?? ''}'.isNotEmpty; final hasAnalysisVideo = '${analysisVideo?['videoUrl'] ?? ''}'.isNotEmpty; // 对标 v-if:audios || videos.length>0 || answerAnalysisVideo.videoUrl || 解析 if (analysis.isEmpty && audios.isEmpty && videos.isEmpty && !hasAnalysisVideo) { return; } final content = [ // 音视频在解析文字之前 for (final audio in audios) Padding( padding: EdgeInsets.only(top: 10.h), child: AudioPlayerCard( url: '${audio['audioUrl'] ?? audio['url'] ?? ''}', ), ), if (hasVideo) for (final video in videos) Padding( padding: EdgeInsets.only(top: 10.h), child: VideoPlayerView( url: '${video['videoUrl'] ?? video['url'] ?? ''}', ), ), if (hasAnalysisVideo) Padding( padding: EdgeInsets.only(top: 10.h), child: VideoPlayerView( url: '${analysisVideo?['videoUrl'] ?? ''}', ), ), if (analysis.isNotEmpty) _buildAnalysisBody(analysis), ]; children.add(_buildAnalysisBox(content, minHeight: true)); } /// 对标 .analysis-body(32rpx / 1.55 / #1D2B47,margin 20rpx 0 24rpx) Widget _buildAnalysisBody(String analysis) { return Padding( padding: EdgeInsets.only(top: 10.h, bottom: 12.h), child: RichTextContent( html: cleanRichText(analysis), textStyle: TextStyle( fontSize: 16.sp, height: 1.55, color: ExerciseColors.title, ), ), ); } /// 对标 .analysis-box(单题页 CSS:margin 66rpx 0 40rpx、min-height 200rpx) Widget _buildAnalysisBox(List content, {bool minHeight = false}) { return Container( constraints: minHeight ? BoxConstraints(minHeight: 100.h) : null, margin: EdgeInsets.only(top: 33.h, bottom: 20.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.r), // 对标 border-box 渐变 146.26deg gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF95F8E7), Color(0xFFB9DDFF), Color(0xFF99DCFE)], stops: [0, 0.4952, 1], ), ), padding: EdgeInsets.all(1.5.w), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.r - 1.5.w), // 对标 padding-box 渐变 180deg gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFE0F9FF), Colors.white, Colors.white], stops: [0, 0.3, 1], ), ), child: Stack( clipBehavior: Clip.none, children: [ Padding( // 对标 padding: 42rpx 28rpx 28rpx padding: EdgeInsets.fromLTRB(14.w, 21.h, 14.w, 14.h), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: content, ), ), // 对标 .analysis-left-bg(156rpx,left -2rpx,top -36rpx) Positioned( left: -1.w, top: -18.h, child: CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-16/analysis.png'), width: 78.w, placeholder: (_, _) => SizedBox(width: 78.w), errorWidget: (_, _, _) => SizedBox(width: 78.w), ), ), // 对标 .analysis-right-icon(180rpx,right -10rpx) Positioned( right: -5.w, top: 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-08/analysis-right-icon.png'), width: 90.w, placeholder: (_, _) => SizedBox(width: 90.w), errorWidget: (_, _, _) => SizedBox(width: 90.w), ), ), ], ), ), ); } // ===================== 状态条 / 答案标题 / 答案内容 ===================== /// 对标 .obj-options-li-view-correct / error Widget _buildStatusStrip(QuestionModel data) { final status = data.answerStatus; final isCorrect = status == 10; final text = isCorrect ? '作答正确' : status == 11 ? '作答有误' : status == 12 ? '部分正确' : '未作答'; final color = isCorrect ? ExerciseColors.correct : ExerciseColors.error; final icon = isCorrect ? '/StaticResource/2026-06-01/correct-icon1780284329572.png' : '/StaticResource/2026-06-01/error-icon1780284292405.png'; return GradientBorderBox( state: isCorrect ? ExerciseTileState.correct : ExerciseTileState.error, padding: EdgeInsets.all(12.w), child: Row( mainAxisSize: MainAxisSize.max, children: [ CachedNetworkImage( imageUrl: ImgUrl.build(icon), width: 20.w, height: 20.w, placeholder: (_, _) => SizedBox(width: 20.w, height: 20.w), errorWidget: (_, _, _) => SizedBox(width: 20.w, height: 20.w), ), SizedBox(width: 10.w), Text( text, style: TextStyle(fontSize: 16.sp, color: color), ), ], ), ); } /// 对标 .answer-box-title(title-bacs.png,bottom right,119rpx) Widget _buildAnswerTitle(String text, {double? fontSize}) { return Stack( children: [ // 标题装饰底图:固定尺寸,位置可调(对标 background-size: 119rpx auto) Positioned( right: 10.w, bottom: 0, child: Image.network( ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'), width: 59.5.w, height: 9.h, fit: BoxFit.fill, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), Padding( padding: EdgeInsets.only(right: 14.w), child: Text( text, style: TextStyle( fontSize: fontSize ?? 15.sp, fontWeight: FontWeight.w600, color: ExerciseColors.title, ), ), ), ], ); } /// 对标 .answer-box-content(富文本) Widget _buildAnswerContent(String html, Color color) { return Container( width: double.infinity, margin: EdgeInsets.symmetric(vertical: 12.h), padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: ExerciseColors.bg, borderRadius: BorderRadius.circular(14.r), ), child: RichTextContent( html: cleanRichText(html), textStyle: TextStyle(fontSize: 16.sp, color: color), ), ); } /// 对标 .answer-box-content(纯文本:wrongReason / wrongAdvice) Widget _buildPlainContent(String text, Color color) { return Container( width: double.infinity, margin: EdgeInsets.symmetric(vertical: 12.h), padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: ExerciseColors.bg, borderRadius: BorderRadius.circular(14.r), ), child: Text( text, style: TextStyle(fontSize: 16.sp, color: color, height: 1.5), ), ); } /// 对标 child.answerAnalysis?.analysis || child.analysis || child.answerAnalysis String _analysisText(QuestionModel q) { final raw = q.answerAnalysisRaw; if (raw is Map) { final first = '${raw['analysis'] ?? ''}'; if (first.isNotEmpty) return first; } if (q.analysis.isNotEmpty) return q.analysis; if (raw != null && '$raw'.isNotEmpty) return '$raw'; return ''; } }