Files
2026-09-28 20:12:34 +08:00

999 lines
33 KiB
Dart
Raw Permalink 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.
/// ============================================================
/// 【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<ExerciseQuestionViewState>` 暴露:
/// 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<String, dynamic> payload);
typedef QuestionUpdateAnswerCallback = void Function(String value);
typedef QuestionNotesCallback = void Function(List<UploadItem> 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<double> panelHeight;
final QuestionSelectCallback? onSelectAnswer;
final QuestionUpdateAnswerCallback? onUpdateAnswer;
final QuestionNotesCallback? onNotesChange;
final QuestionSubChangeCallback? onSubQuestionChange;
final QuestionStemLinkCallback? onStemLinkTap;
@override
ExerciseQuestionViewState createState() => ExerciseQuestionViewState();
}
class ExerciseQuestionViewState extends State<ExerciseQuestionView> {
final ScrollController _scrollController = ScrollController();
/// 本题媒体暂停注册表:子树内播放器自动注册,切页时直接暂停,
/// 不再运行时递归 visitChildElements(避免 element 树更新期断言)
final MediaPauseRegistry _mediaRegistry = MediaPauseRegistry();
/// 题干 HtmlWidget(锚点滚动用)
final GlobalKey<HtmlWidgetState> _stemHtmlKey = GlobalKey<HtmlWidgetState>();
/// 图片作答 Upload(uploadAnswerRef)
final GlobalKey<UploadState> _answerUploadKey = GlobalKey<UploadState>();
/// 笔记 Upload(uploadRef)
final GlobalKey<UploadState> _notesKey = GlobalKey<UploadState>();
late int _activeSubIndex = widget.activeSubIndex;
/// 图片作答缓存(惰性初始化,保持引用稳定,避免 Upload 被重置)
List<UploadItem>? _answerItems;
/// 笔记图片缓存
List<UploadItem>? _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<void> uploadLectureNotes() async {
return _notesKey.currentState?.chooseImage();
}
/// 取图片列表(对标 getImageList)
/// - type == 'uploadAnswerRef':作答图,兜底 safeImageAnswerList(userAnswer)
/// - 其它:笔记图,兜底 safeImageAnswerList(notes)
List<dynamic> 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<bool> _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<String, dynamic> 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<UploadItem> items) {
_answerItems = List<UploadItem>.from(items);
widget.onUpdateAnswer
?.call(QuestionDataAdapter.uploadItemsToJson(items));
}
/// 对标 handleNotesChange
void _onNotesChanged(List<UploadItem> items) {
_notesItems = List<UploadItem>.from(items);
widget.onNotesChange?.call(items);
}
List<UploadItem> get _answerUploadItems {
return _answerItems ??= QuestionDataAdapter.toUploadItems(
QuestionDataAdapter.parseImageAnswerList(widget.data.userAnswer),
);
}
List<UploadItem> 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 = <Widget>[
// ---- 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<double>(
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<Widget> 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<Widget> 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<Widget> 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<Widget> 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<Widget> 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 = <Widget>[
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<Widget> 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 = <Widget>[
// 音视频在解析文字之前
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<Widget> 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 '';
}
}