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

1243 lines
40 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.
/// ============================================================
/// 【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<Map<String, dynamic>> 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<CompoundQuestionPanel> {
/// 三档停靠点(屏高比例,对标原锚点 18% / 50% / 75%)
static const List<double> _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<int, GlobalKey> _tabKeys = {};
final Map<int, GlobalKey<_SubPageState>> _subKeys = {};
/// 各子题独立的媒体暂停注册表(与 _subKeys 同生命周期)
final Map<int, MediaPauseRegistry> _subRegistries = {};
/// 每个子题 Upload 组件的当前图片缓存(切子题/翻页不丢,
/// 同时作为 finalizeChildAnswer 的取数来源,对标 uploadAnswerRefs)
final Map<int, List<UploadItem>> _answerUploadCache = {};
final Map<int, List<UploadItem>> _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<String, dynamic> 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(',') : <String>[];
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<UploadItem> items) {
if (widget.mode != CompoundMode.edit) return;
final child = widget.detail.childAt(index);
if (child == null) return;
_answerUploadCache[index] = List<UploadItem>.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<UploadItem> items) {
_fillblankUploadCache[index] = List<UploadItem>.from(items);
widget.onFillBlankImgChange
?.call(index, QuestionDataAdapter.uploadItemsToAnswerImgs(items));
}
void _emitMyAnswer(int index, QuestionModel child) {
setState(() {});
widget.onMyAnswer?.call(index, child);
}
// ===================== Upload 缓存取数 =====================
List<UploadItem> fillblankItemsFor(int index, String rawAnswer) {
return _fillblankUploadCache.putIfAbsent(
index,
() => QuestionDataAdapter.toUploadItems(
QuestionDataAdapter.parseImageAnswerList(rawAnswer)),
);
}
List<UploadItem> 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<QuestionModel> 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<bool> _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 = <Widget>[];
// ---- 子题自己的题干(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<Widget> 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<Widget> 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 = <Widget>[];
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,
),
);
}
}