This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
@@ -0,0 +1,998 @@
/// ============================================================
/// 【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 '';
}
}