999 lines
33 KiB
Dart
999 lines
33 KiB
Dart
/// ============================================================
|
||
/// 【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 '';
|
||
}
|
||
}
|