/// ============================================================ /// 【CompoundQuestionPanel】复合题浮动面板 /// (1:1 迁移 Vue components/CompoundQuestion/CompoundQuestionNew.vue) /// /// 面板本体基于 PersistentSnapSheet(smooth_sheets 封装)的常驻多停靠面板: /// 1. 固定在页面底部的非模态浮层,三档停靠点 18% / 50% / 75% 屏高, /// 默认中档;整个面板区域可自由拖拽,松手自动吸附最近档, /// 点击顶部把手循环切档; /// 2. 顶部 tabbar 横滑,每个子题一个圆点(编辑蓝点=未答;查看 /// 绿=对/红=错/灰=未答),点击切换并滚动到该项; /// 3. 下方 PageView 左右滑动切子题,每子题独立滚动,切回顶部; /// 子题列表滚到顶后继续下拉可拖动面板(onlyFromTop 联动); /// 4. 子题内容:子题干 / objOptions / AnswerView / 填空输入框 / /// 图片作答 Upload / 主观题图片 Upload / 解析盒; /// 5. 交互事件(与 Vue emit 同名同义): /// - onMyAnswer(index, child) → update:myAnswer /// - onSubSwitch(index, fromChild) → update:handleSubSwitch /// - onActiveSubIndexChanged(index) → update:activeSubIndex /// - onFillBlankImgChange(index, images) → FilllankImgChange /// - onPanelHeightChanged(heightPx) → update:panelHeight /// /// 说明: /// - 组件内部已含 SheetViewport,父级直接放进 Stack 即可,不要再包 Positioned; /// - 父级需以 ValueKey(detail.id) 挂载,切大题时整体重建, /// 子题间切换由 activeSubIndex 入参回灌(didUpdateWidget); /// - Signature 已废弃:多空图片作答统一用 Upload(aiGrading, maxCount:1)。 /// ============================================================ library; import 'dart:async'; import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.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/persistent_snap_sheet.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:smooth_sheets/smooth_sheets.dart'; import '../models/question_data_adapter.dart'; import '../models/question_model.dart'; import 'answer_view.dart'; import 'exercise_styles.dart'; import 'obj_options.dart'; /// 对应 Vue props.mode enum CompoundMode { edit, view } /// 对应 defineExpose 的 getChildData() 返回值 {index, data} class CompoundChildData { const CompoundChildData({required this.index, required this.data}); final int index; final QuestionModel data; } typedef CompoundMyAnswerCallback = void Function(int index, QuestionModel data); typedef CompoundSubSwitchCallback = void Function(int index, QuestionModel data); typedef CompoundActiveSubCallback = void Function(int index); typedef CompoundFillBlankCallback = void Function( int subIndex, List> images); typedef CompoundPanelHeightCallback = void Function(double heightPx); class CompoundQuestionPanel extends StatefulWidget { const CompoundQuestionPanel({ super.key, required this.detail, this.mode = CompoundMode.edit, this.activeSubIndex = 0, this.isActive = false, this.isBottomH = true, this.isScore = false, this.onMyAnswer, this.onSubSwitch, this.onActiveSubIndexChanged, this.onFillBlankImgChange, this.onPanelHeightChanged, }); /// 复合题大题数据(与 questionsList 元素同一 Map 引用) final QuestionModel detail; final CompoundMode mode; /// 当前激活子题下标(外部回灌) final int activeSubIndex; /// 本面板所属大题是否为当前展示大题 final bool isActive; /// 是否预留底部按钮高度(classExercises 页固定传 false) final bool isBottomH; /// 为 true 时不显示子题分数(classExercises 固定 true) final bool isScore; final CompoundMyAnswerCallback? onMyAnswer; final CompoundSubSwitchCallback? onSubSwitch; final CompoundActiveSubCallback? onActiveSubIndexChanged; final CompoundFillBlankCallback? onFillBlankImgChange; final CompoundPanelHeightCallback? onPanelHeightChanged; @override CompoundQuestionPanelState createState() => CompoundQuestionPanelState(); } class CompoundQuestionPanelState extends State { /// 三档停靠点(屏高比例,对标原锚点 18% / 50% / 75%) static const List _snapFactors = [0.18, 0.50, 0.75]; /// 当前停靠档(点击把手循环切档用) int _anchorIndex = 1; /// 面板位置控制器(拖拽 / 代码切档 / 高度监听) late final SheetController _sheetController = SheetController(); late int _tabbarIndex = _initialIndex(); late int _lastActiveIndex = _tabbarIndex; /// swiper 动画锁(对标 isSwiperAnimating,防止 change 来回闪烁) bool _isAnimating = false; Timer? _animatingTimer; late final PageController _pageController = PageController(initialPage: _tabbarIndex); final ScrollController _tabbarScrollController = ScrollController(); final Map _tabKeys = {}; final Map> _subKeys = {}; /// 各子题独立的媒体暂停注册表(与 _subKeys 同生命周期) final Map _subRegistries = {}; /// 每个子题 Upload 组件的当前图片缓存(切子题/翻页不丢, /// 同时作为 finalizeChildAnswer 的取数来源,对标 uploadAnswerRefs) final Map> _answerUploadCache = {}; final Map> _fillblankUploadCache = {}; int get _subCount { final subs = widget.detail.subQuestions.length; return subs > 0 ? subs : widget.detail.childs.length; } int _initialIndex() { final count = widget.detail.subQuestions.length; final max = count > 0 ? count - 1 : 0; return widget.activeSubIndex.clamp(0, max); } static String _isoNow() => DateTime.now().toIso8601String(); // ===================== 生命周期 ===================== @override void initState() { super.initState(); // 对标 watch(detail, immediate) + watch(isActive, immediate) if (widget.isActive) { final child = widget.detail.childAt(_tabbarIndex); if (child != null && (child.startTime == null || child.startTime!.isEmpty)) { child.startTime = _isoNow(); } } _sheetController.addListener(_onSheetMetricsChanged); // 首帧后 metrics 就绪,回传一次初始高度 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _onSheetMetricsChanged(); }); } @override void didUpdateWidget(covariant CompoundQuestionPanel oldWidget) { super.didUpdateWidget(oldWidget); // 正常情况下父级以 ValueKey(detail.id) 重建;兜底清理缓存 if (!identical(widget.detail, oldWidget.detail)) { _answerUploadCache.clear(); _fillblankUploadCache.clear(); } // 对标 watch(isActive) if (widget.isActive != oldWidget.isActive) { final child = widget.detail.childAt(_tabbarIndex); if (widget.isActive) { if (child != null && (child.startTime == null || child.startTime!.isEmpty)) { child.startTime = _isoNow(); } } else { if (child != null && child.startTime != null && child.startTime!.isNotEmpty && (child.endTime == null || child.endTime!.isEmpty)) { _finalizeChildAnswer(_tabbarIndex); child.endTime = _isoNow(); widget.onMyAnswer?.call(_tabbarIndex, child); } } } // 对标 watch(activeSubIndex):外部回灌只做视觉切换,不走 handleSubSwitch final ext = widget.activeSubIndex; if (ext != _tabbarIndex) { _lastActiveIndex = ext; final child = widget.detail.childAt(ext); if (widget.isActive && child != null) { child.startTime = _isoNow(); } _pauseSubMedia(_tabbarIndex); setState(() => _tabbarIndex = ext); if (_pageController.hasClients) { _pageController.animateToPage( ext, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } _scrollSubPageToTop(ext); _isAnimating = true; _animatingTimer?.cancel(); _animatingTimer = Timer(const Duration(milliseconds: 500), () { _isAnimating = false; _ensureTabVisible(ext, 0); }); } } @override void dispose() { _animatingTimer?.cancel(); _pageController.dispose(); _tabbarScrollController.dispose(); _sheetController.dispose(); super.dispose(); } // ===================== 对外暴露(对标 defineExpose) ===================== /// 返回当前子题的 subQuestion(startTime 已从 childs 同步) CompoundChildData? getChildData() { final sub = widget.detail.subAt(_tabbarIndex) ?? widget.detail.childAt(_tabbarIndex); if (sub == null) return null; final child = widget.detail.childAt(_tabbarIndex); if (child != null) sub.startTime = child.startTime; return CompoundChildData(index: _tabbarIndex, data: sub); } /// 暂停当前子题内所有音频/视频(对标 uni.$emit('globalMediaPlay')) void pauseMedia() => _pauseSubMedia(_tabbarIndex); // ===================== 面板位置 / 高度 ===================== /// SheetController 监听回调:同步当前停靠档,并把面板像素高度回传父级 void _onSheetMetricsChanged() { final metrics = _sheetController.metrics; if (metrics == null) return; // 找出距当前 offset 最近的停靠档(弹性过界时也成立) final viewportH = metrics.viewportSize.height; var nearest = 0; var best = double.infinity; for (var i = 0; i < _snapFactors.length; i++) { final diff = (viewportH * _snapFactors[i] - metrics.offset).abs(); if (diff < best) { best = diff; nearest = i; } } _anchorIndex = nearest; widget.onPanelHeightChanged?.call(metrics.offset); } /// 点击把手:循环跳到下一档(拖拽手势由 Sheet 自身接管) void _onHeaderTap() { final next = (_anchorIndex + 1) % _snapFactors.length; _anchorIndex = next; _sheetController.animateTo(SheetOffset(_snapFactors[next])); } // ===================== 子题切换 ===================== void _onTabTap(int i) { if (_isAnimating) return; if (i == _tabbarIndex) return; _switchToByGesture(i); } void _onPageChanged(int i) { if (_isAnimating) return; if (i == _tabbarIndex) return; _switchToByGesture(i); } /// 子题链接 #subquestion-N 点击 → 等同 tabbar 点击 void onStemLinkTap(int i) { if (i < 0 || i >= _subCount || i == _tabbarIndex) return; if (_isAnimating) return; _switchToByGesture(i); } void _switchToByGesture(int target) { _isAnimating = true; _pauseSubMedia(_lastActiveIndex); _handleSubSwitch(_lastActiveIndex, target); setState(() => _tabbarIndex = target); _pageController.animateToPage( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); _scrollSubPageToTop(target); widget.onActiveSubIndexChanged?.call(target); _animatingTimer?.cancel(); _animatingTimer = Timer(const Duration(milliseconds: 300), () { _isAnimating = false; _ensureTabVisible(target, 0); }); } /// 对标 handleSubSwitch:结掉 from 子题时间并上报,开 to 子题时间 void _handleSubSwitch(int from, int to) { if (from == to) return; final now = _isoNow(); final fromChild = widget.detail.childAt(from); if (fromChild != null) { _finalizeChildAnswer(from); fromChild.endTime = now; widget.onSubSwitch?.call(from, fromChild); } widget.detail.childAt(to)?.startTime = now; _lastActiveIndex = to; } /// 对标 finalizeChildAnswer:离开子题前把图片作答写回 child.userAnswer void _finalizeChildAnswer(int index) { final child = widget.detail.childAt(index); final sub = widget.detail.subAt(index); if (child == null || sub == null) return; final isObjective = child.isObjective || const ['danxuanti', 'duoxuanti', 'panduanti'] .contains(sub.questionTypes); final isBlank = sub.questionTypes == 'tiankongti'; final isUpload = !isObjective && !isBlank; if (!isUpload) return; final items = _answerUploadCache[index]; if (items == null) return; final imgs = QuestionDataAdapter.uploadItemsToAnswerImgs(items) .where((e) => '${e['Url'] ?? ''}'.isNotEmpty) .toList(); if (imgs.isNotEmpty) { child.userAnswer = jsonEncode(imgs); } } void _pauseSubMedia(int index) { // 直接调注册表,避免切子题动画期间递归遍历 element 树 _subRegistries[index]?.pauseAll(); } void _scrollSubPageToTop(int index) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _subKeys[index]?.currentState?.scrollToTop(); }); } void _ensureTabVisible(int index, int delayMs) { Future.delayed(Duration(milliseconds: delayMs), () { if (!mounted) return; final ctx = _tabKeys[index]?.currentContext; if (ctx != null && ctx.mounted) { Scrollable.ensureVisible( ctx, alignment: 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } // ===================== 作答回调(子页 → 面板 → 父级) ===================== /// 对标 handleSelect void handleSelect(int index, Map val) { if (widget.mode != CompoundMode.edit) return; final child = widget.detail.childAt(index); if (child == null) return; if (val.containsKey('userAnswer')) { final raw = val['userAnswer']; child.userAnswer = raw != null && '$raw'.isNotEmpty ? '$raw' : ''; _emitMyAnswer(index, child); return; } // 老逻辑兜底(迁移后的 ObjOptions 统一走 userAnswer,此分支基本不触发) final label = '${val['optionLabel'] ?? ''}'; var currentAnswers = child.userAnswer.isNotEmpty ? child.userAnswer.split(',') : []; if (child.questionTypeName == '多选题') { if (currentAnswers.contains(label)) { currentAnswers = currentAnswers.where((a) => a != label).toList(); } else { currentAnswers.add(label); } currentAnswers.sort(); } else { currentAnswers = [label]; } child.userAnswer = currentAnswers.join(','); _emitMyAnswer(index, child); } /// 对标 onInputBlank void onInputBlank(int index, String value) { if (widget.mode != CompoundMode.edit) return; final child = widget.detail.childAt(index); if (child == null) return; child.userAnswer = value; widget.onMyAnswer?.call(index, child); } /// 对标 handleUploadChange(主观题图片作答,maxCount 5) void onAnswerUploadChanged(int index, List items) { if (widget.mode != CompoundMode.edit) return; final child = widget.detail.childAt(index); if (child == null) return; _answerUploadCache[index] = List.from(items); final imgs = QuestionDataAdapter.uploadItemsToAnswerImgs(items); child.userAnswer = imgs.isNotEmpty ? jsonEncode(imgs) : ''; _emitMyAnswer(index, child); } /// 对标 FilllankUploadChange(多空图片作答,maxCount 1), /// 面板本身不写 child,由父级双写 subQuestions/childs void onFillBlankChanged(int index, List items) { _fillblankUploadCache[index] = List.from(items); widget.onFillBlankImgChange ?.call(index, QuestionDataAdapter.uploadItemsToAnswerImgs(items)); } void _emitMyAnswer(int index, QuestionModel child) { setState(() {}); widget.onMyAnswer?.call(index, child); } // ===================== Upload 缓存取数 ===================== List fillblankItemsFor(int index, String rawAnswer) { return _fillblankUploadCache.putIfAbsent( index, () => QuestionDataAdapter.toUploadItems( QuestionDataAdapter.parseImageAnswerList(rawAnswer)), ); } List answerUploadItemsFor(int index, String rawAnswer) { return _answerUploadCache.putIfAbsent( index, () => QuestionDataAdapter.toUploadItems( QuestionDataAdapter.parseImageAnswerList(rawAnswer)), ); } // ===================== tabbar 展示 ===================== /// 对标 getQuestionStatusClass → 圆点颜色 Color? _tabDotColor(int i) { final child = widget.detail.childAt(i); if (child == null) return const Color(0xFF94A5B8); final answer = child.effectiveAnswer; if (widget.mode == CompoundMode.edit) { final imgs = QuestionDataAdapter.parseImageAnswerList(answer); if (answer == '[]') return ExerciseColors.primary; if (imgs.isEmpty && answer.isEmpty) return ExerciseColors.primary; return null; } final status = child.answerStatus; if (status != null) { if (status == 10) return ExerciseColors.correct; if (status == 11) return ExerciseColors.error; // 0 未答 / 1 判题中 / 12 等其他情况,统一视为未答 return const Color(0xFF94A5B8); } if (answer.isEmpty) return const Color(0xFF94A5B8); final uAns = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim(); final cAns = (child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer) .replaceAll(RegExp(r'<[^>]+>'), '') .trim(); return uAns == cAns ? ExerciseColors.correct : ExerciseColors.error; } /// 对标 formatSubIndex:无 index → "(i+1)题";去点;含"题"直接用 String _formatSubIndex(dynamic subIndex, int i) { if (subIndex == null || '$subIndex'.isEmpty) return '${i + 1}题'; final s = '$subIndex'.replaceAll('.', '').trim(); if (s.contains('题')) return s; return '$s题'; } /// 对标 cutStr:截到小数点后 1 位(不四舍五入) String _cutStr(dynamic num) { final s = '$num'; final dot = s.indexOf('.'); if (dot == -1) return s; return s.substring(0, dot + 2); } GlobalKey _tabKey(int i) => _tabKeys.putIfAbsent(i, () => GlobalKey()); GlobalKey<_SubPageState> _subKey(int i) => _subKeys.putIfAbsent(i, () => GlobalKey<_SubPageState>()); // ===================== Build ===================== @override Widget build(BuildContext context) { final subs = widget.detail.subQuestions; final safeBottom = MediaQuery.viewPaddingOf(context).bottom; // 常驻多停靠面板(拖拽 / 吸附由 PersistentSnapSheet 接管) return PersistentSnapSheet( snapFactors: _snapFactors, initialOffsetIndex: 1, controller: _sheetController, decoration: const _PanelSheetDecoration(), child: Column( mainAxisSize: MainAxisSize.max, children: [ _buildHeader(), _buildTabbar(subs), Expanded( child: Container( color: Colors.white, child: PageView.builder( controller: _pageController, itemCount: _subCount, onPageChanged: _onPageChanged, itemBuilder: (context, i) { return MediaPauseScope( registry: _subRegistries.putIfAbsent( i, () => MediaPauseRegistry()), child: _SubPage( key: _subKey(i), panel: this, index: i, ), ); }, ), ), ), // 对标 dynamicPaddingBottom(isBottomH=false → 10.h + 安全区) SizedBox(height: (widget.isBottomH ? 70.h : 10.h) + safeBottom), ], ), ); } /// 对标 l-floating-panel__header(30px 高,75rpx 宽把手)。 /// 拖拽手势由 Sheet 接管,这里仅保留点击循环切档。 Widget _buildHeader() { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _onHeaderTap, child: SizedBox( height: 30, child: Center( child: Container( width: 37.5.w, height: 3, decoration: BoxDecoration( color: const Color(0xFFB4C2E0), borderRadius: BorderRadius.circular(3), ), ), ), ), ); } Widget _buildTabbar(List subs) { final list = subs.isNotEmpty ? subs : widget.detail.childs; // 对标 Vue scroll-view.width-100:强制撑满面板宽度, // 子题不足一屏时标签从左侧开始排列(否则整条会被 Column 居中) return Container( width: double.infinity, decoration: BoxDecoration( border: Border( bottom: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w), ), ), child: SingleChildScrollView( controller: _tabbarScrollController, scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < list.length; i++) _buildTabItem(list[i], i), ], ), ), ); } Widget _buildTabItem(QuestionModel sub, int i) { final active = i == _tabbarIndex; final dot = _tabDotColor(i); final scoreRaw = sub.m['score']; final hasScore = scoreRaw != null && '$scoreRaw'.isNotEmpty && '$scoreRaw' != '0'; final showScore = hasScore && !widget.isScore; final label = Text( _formatSubIndex(sub.index, i), style: TextStyle( fontSize: 15.sp, color: active ? ExerciseColors.primary : ExerciseColors.title, fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _onTabTap(i), child: Padding( key: _tabKey(i), padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), child: Column( mainAxisSize: MainAxisSize.min, children: [ Stack( clipBehavior: Clip.none, children: [ label, // 对标 .tabbar-li-status::after 状态圆点 if (dot != null) Positioned( right: -12.w, top: 0, child: Container( width: 10.w, height: 10.w, decoration: BoxDecoration( color: dot, shape: BoxShape.circle, ), ), ), // 对标 .tabbar-li-category::after 选中下划线 if (active) Positioned( left: 0, right: 0, bottom: showScore ? -20.h : -12.h, height: 2.h, child: Container( decoration: BoxDecoration( color: ExerciseColors.primary, borderRadius: BorderRadius.circular(1.r), ), ), ), ], ), if (showScore) Padding( padding: EdgeInsets.only(top: 2.h), child: Text( '${_cutStr(scoreRaw)}分', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, color: ExerciseColors.title, ), ), ), ], ), ), ); } } // ============================================================ // 子题页(swiper-item) // ============================================================ class _SubPage extends StatefulWidget { const _SubPage({ super.key, required this.panel, required this.index, }); final CompoundQuestionPanelState panel; final int index; @override State<_SubPage> createState() => _SubPageState(); } class _SubPageState extends State<_SubPage> with AutomaticKeepAliveClientMixin { /// 子页独立的 SheetScrollController(滚动与面板拖拽联动), /// 由下方 SheetScrollable 接上所属 Sheet 的 model delegate late final SheetScrollController _scrollController = SheetScrollController(); TextEditingController? _blankController; @override bool get wantKeepAlive => true; CompoundQuestionPanelState get panel => widget.panel; int get index => widget.index; @override void dispose() { _scrollController.dispose(); _blankController?.dispose(); super.dispose(); } void scrollToTop() { if (_scrollController.hasClients) { _scrollController.jumpTo(0); } } Future _onStemTap(String url) async { if (url.startsWith('#subquestion-')) { final idx = int.tryParse(url.replaceFirst('#subquestion-', '')); if (idx != null) { panel.onStemLinkTap(idx); return true; } } return false; } @override Widget build(BuildContext context) { super.build(context); final detail = panel.widget.detail; final mode = panel.widget.mode; final isEdit = mode == CompoundMode.edit; final isView = mode == CompoundMode.view; final sub = detail.subAt(index); final child = detail.childAt(index); if (sub == null || child == null) return const SizedBox.shrink(); final children = []; // ---- 子题自己的题干(sub.stem) ---- if (sub.stem.isNotEmpty) { children.add(Padding( padding: EdgeInsets.only(bottom: 10.h), child: RichTextContent( html: cleanRichText(stripOptionTable(sub.stem)), textStyle: TextStyle( fontSize: 16.sp, color: const Color(0xFF333333), height: 1.6, fontWeight: FontWeight.bold, ), onTapUrl: _onStemTap, ), )); } // ---- 客观题判定(与模板条件 1:1) ---- const objectiveTypes = [ 'danxuanti', 'duoxuanti', 'panduanti', 'yueduxuanzeti', 'wanxingtiankongxuanzeti', ]; final isObjectiveFlag = child.isObjective || objectiveTypes.contains(sub.questionTypes) || sub.isSelect; final hasOptions = sub.options.isNotEmpty; final correct = child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer; final myAnswer = child.effectiveAnswer; if (hasOptions && isObjectiveFlag) { children.add(ObjOptions( options: sub.options, questionType: sub.questionTypes, mode: isEdit ? ObjOptionMode.edit : ObjOptionMode.view, answerData: ObjOptionAnswerData( correctAnswer: correct, myAnswer: myAnswer, ), onChanged: (payload) => panel.handleSelect(index, payload), )); } if (isView && isObjectiveFlag) { children.add(Padding( padding: EdgeInsets.only(bottom: 10.h), child: ObjectiveAnswerView( answerStatus: child.answerStatus, userAnswer: myAnswer, correctAnswer: correct, ), )); } else { _buildNonObjective(children, detail, sub, child, isEdit, isView); } // ---- 解析盒 ---- _appendAnalysis(children, child); // 底部预留 140rpx children.add(SizedBox(height: 70.h)); return SheetScrollable( controller: _scrollController, child: SingleChildScrollView( controller: _scrollController, padding: EdgeInsets.only(left: 14.w, right: 14.w, top: 12.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: children, ), ), ); } // ===================== 非客观题分支 ===================== void _buildNonObjective( List children, QuestionModel detail, QuestionModel sub, QuestionModel child, bool isEdit, bool isView, ) { final answer = child.effectiveAnswer; final images = QuestionDataAdapter.parseImageAnswerList(answer); final isImageAnswer = images.isNotEmpty; // 分支 1:多空图片作答填空题 // (detail.iSFilllankItemUpload == 1,或查看态且答案为图片数组) final imageBlank = (detail.isFillblankItemUpload == 1 || (isView && isImageAnswer)) && detail.questionTypes == 'tiankongti'; if (imageBlank) { if (isView) children.add(_buildStatusStrip(child)); // Signature 已废弃:统一用 Upload(aiGrading, maxCount:1) children.add(Upload( title: '上传作答', maxCount: 1, type: UploadType.aiGrading, mode: isEdit ? UploadMode.edit : UploadMode.view, initialImageList: panel.fillblankItemsFor(index, answer), onChanged: (items) => panel.onFillBlankChanged(index, items), )); if (isView) { children.add(_buildAnswerTitle('正确答案')); children.add(_buildAnswerContent( child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer, ExerciseColors.correct, )); } return; } // 分支 2:SHOW_USER_ANSWER(Vue 端常量恒为 false,暂不展示)—— 略 // 分支 3:编辑态输入类填空题 if (isEdit && sub.questionTypes == 'tiankongti') { children.add(_buildBlankTextarea(child)); return; } // 分支 4:查看态输入类填空题 if (isView && sub.questionTypes == 'tiankongti') { children.add(_buildStatusStrip(child)); if (detail.isFillblankItemUpload != 1 && child.answerStatus != 0) { children.add(SizedBox(height: 10.h)); children.add(_buildAnswerTitle('你的答案')); children.add(_buildAnswerContent(answer, const Color(0xFF495A7A))); } children.add(SizedBox(height: 10.h)); children.add(_buildAnswerTitle('正确答案')); children.add(_buildAnswerContent( child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer, ExerciseColors.correct, )); return; } // 分支 5:无选项的主观题(上传图片作答,maxCount 5) if (!hasOptions) { if (isView) children.add(_buildStatusStrip(child)); children.add(Upload( title: '上传图片作答', maxCount: 5, type: UploadType.aiGrading, mode: isEdit ? UploadMode.edit : UploadMode.view, initialImageList: panel.answerUploadItemsFor(index, answer), onChanged: (items) => panel.onAnswerUploadChanged(index, items), )); if (isView) { children.add(SizedBox(height: 10.h)); children.add(_buildAnswerTitle('正确答案')); children.add(_buildAnswerContent( child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer, ExerciseColors.correct, )); } } } bool get hasOptions { final sub = panel.widget.detail.subAt(index); return sub?.options.isNotEmpty ?? false; } /// 填空题输入框(对标 textarea.auto-height + .input-text 样式) Widget _buildBlankTextarea(QuestionModel child) { final controller = _blankController ??= TextEditingController( text: child.effectiveAnswer, ); return Container( // 对标 width: calc(100% - 56rpx) margin: EdgeInsets.only(left: 14.w, right: 14.w, 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: 14.sp, color: const Color(0xFF1D2B47), ), onChanged: (v) => panel.onInputBlank(index, v), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入您的答案...', hintStyle: TextStyle( fontSize: 14.sp, color: const Color(0xFF94A5B8), ), ), ), ); } /// 作答状态条(对标 .obj-options-li-view-correct/error) Widget _buildStatusStrip(QuestionModel child) { final status = child.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.min, 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: 14.sp, color: color), ), ], ), ); } /// 对标 .answer-box-title(title-bacs.png 背景) Widget _buildAnswerTitle(String text) { return Container( padding: EdgeInsets.only(right: 14.w), decoration: BoxDecoration( image: DecorationImage( image: NetworkImage( ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'), ), alignment: Alignment.bottomRight, repeat: ImageRepeat.noRepeat, ), ), child: Text( text, style: TextStyle( 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), ), ); } // ===================== 解析盒(analysis-box) ===================== /// 对标 getAnalysis: /// child.answerAnalysis?.analysis || child.analysis || child.answerAnalysis String _getAnalysis(QuestionModel child) { final raw = child.answerAnalysisRaw; if (raw is Map) { final first = '${raw['analysis'] ?? ''}'; if (first.isNotEmpty) return first; } else if (raw != null && '$raw'.isNotEmpty) { return '$raw'; } if (child.analysis.isNotEmpty) return child.analysis; if (raw != null) return '$raw'; return ''; } void _appendAnalysis(List children, QuestionModel child) { final analysis = _getAnalysis(child); final audios = child.audios; final videos = child.videos .where((v) => '${v['videoUrl'] ?? v['url'] ?? ''}'.isNotEmpty) .toList(); final hasBody = cleanRichText(analysis).isNotEmpty; final hasAudio = audios.isNotEmpty; final hasVideo = videos.isNotEmpty; // 对标 v-if:cleanRichText(getAnalysis) || audios.length || videos[0].videoUrl if (!hasBody && !hasAudio && !hasVideo) return; final content = []; if (hasBody) { content.add(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, ), ), )); } for (final audio in audios) { content.add(Padding( padding: EdgeInsets.only( top: hasBody ? 30.h : 10.h, bottom: hasVideo ? 30.h : 0, ), child: AudioPlayerCard( url: '${audio['audioUrl'] ?? audio['url'] ?? ''}', ), )); } for (final video in videos) { content.add(Padding( padding: EdgeInsets.only( top: (hasBody || hasAudio) ? 30.h : 10.h, ), child: VideoPlayerView( url: '${video['videoUrl'] ?? video['url'] ?? ''}', ), )); } children.add(Container( margin: EdgeInsets.only(top: 43.h, bottom: 40.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: [ Positioned.fill( child: Padding( padding: EdgeInsets.fromLTRB(14.w, 21.h, 14.w, 14.h), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: content, ), ), ), // 对标 .analysis-left-bg Positioned( left: -0.5.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 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), ), ), ], ), ), )); } } // ============================================================ // 面板外观:渐变背景 + 顶部圆角 + 白色描边 + 上方阴影 // (对标原 AnimatedContainer 装饰,尺寸必须与 child 一致, // 故直接用 DecoratedBox + ClipRRect,不额外加 padding) // ============================================================ class _PanelSheetDecoration extends SizedSheetDecoration { const _PanelSheetDecoration() : super(size: SheetSize.stretch); @override Widget build(BuildContext context, Widget child) { final radius = BorderRadius.vertical(top: Radius.circular(16.r)); return DecoratedBox( decoration: BoxDecoration( // 对标 l-floating-panel:180deg #E7F2FB → #FAFDFF 10% → #FFFFFF gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFE7F2FB), Color(0xFFFAFDFF), Colors.white], stops: [0, 0.1, 1], ), borderRadius: radius, border: Border.all(color: Colors.white, width: 1.w), boxShadow: [ BoxShadow( color: const Color(0x14000000), offset: Offset(0, -10.h), blurRadius: 20.h, ), ], ), child: ClipRRect( borderRadius: radius, child: child, ), ); } }