更新
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
/// ============================================================
|
||||
/// 【AnswerCardPopup】答题卡弹层
|
||||
/// (1:1 迁移 Vue components/AnswerCardPopup/AnswerCardPopup.vue)
|
||||
///
|
||||
/// statusMode:
|
||||
/// answer 答题中:未答 / 已答 /(扩展)时长不足 / 待补笔记,底部双按钮
|
||||
/// result 查看结果:答对 / 答错 / 未答,无底部按钮
|
||||
///
|
||||
/// 数据:buildExamAnswerCardQuestions 拍平 → 按 sort 排序 →
|
||||
/// 相邻 questionTypeName 分组。
|
||||
/// 事件:onSelect(questionMap) 选择题目(同时关闭);
|
||||
/// onCancel 继续答题/关闭;onSubmit 交卷。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
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/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
/// 答题卡状态码
|
||||
class AnswerCardStatus {
|
||||
AnswerCardStatus._();
|
||||
|
||||
static const int unanswered = 0;
|
||||
static const int answered = 1;
|
||||
static const int correct = 2;
|
||||
static const int wrong = 3;
|
||||
static const int planTimeNotEnough = 20;
|
||||
static const int pendingNotes = 21;
|
||||
}
|
||||
|
||||
class AnswerCardPopup extends StatelessWidget {
|
||||
const AnswerCardPopup({
|
||||
super.key,
|
||||
required this.show,
|
||||
required this.questions,
|
||||
this.isExamAnswerCard = true,
|
||||
this.statusMode = 'answer',
|
||||
this.enableExtendedAnswerStatus = false,
|
||||
this.onCancel,
|
||||
this.onSubmit,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
|
||||
/// QuestionModel.m 原始题目 Map 列表(同一引用即可,内部只读取/拷贝)
|
||||
final List<Map<String, dynamic>> questions;
|
||||
final bool isExamAnswerCard;
|
||||
|
||||
/// answer / result
|
||||
final String statusMode;
|
||||
final bool enableExtendedAnswerStatus;
|
||||
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback? onSubmit;
|
||||
final ValueChanged<Map<String, dynamic>>? onSelect;
|
||||
|
||||
bool get _extended =>
|
||||
statusMode == 'answer' && enableExtendedAnswerStatus;
|
||||
|
||||
// ---------------- 状态判定(1:1 移植 Vue) ----------------
|
||||
|
||||
String _normalizeAnswer(dynamic value) =>
|
||||
'$value'.replaceAll(RegExp(r'<[^>]+>'), '').replaceAll(RegExp(r'\s+'), '').toUpperCase();
|
||||
|
||||
bool _hasUploadedNotes(dynamic notes) {
|
||||
if (notes is List) return notes.isNotEmpty;
|
||||
return hasValue(notes);
|
||||
}
|
||||
|
||||
bool _hasFinishedPlanTime(Map<String, dynamic> q) {
|
||||
final planTimeSecond = _num(q['planTimeSecond']);
|
||||
if (planTimeSecond <= 0) return true;
|
||||
if (q['ISFinishPlanTime'] == true) return true;
|
||||
final answerTimeSecond = _num(q['answerTimeSecond']);
|
||||
return answerTimeSecond > planTimeSecond;
|
||||
}
|
||||
|
||||
int _questionStatus(Map<String, dynamic> q) {
|
||||
final fillBlankAnswered = isFillBlankFullyAnswered(q);
|
||||
final answered = (fillBlankAnswered == null || !fillBlankAnswered)
|
||||
? hasValue(q['userAnswer'])
|
||||
: fillBlankAnswered;
|
||||
|
||||
if (statusMode == 'answer') {
|
||||
if (!answered) return AnswerCardStatus.unanswered;
|
||||
if (_extended &&
|
||||
_num(q['planTimeSecond']) > 0 &&
|
||||
!_hasFinishedPlanTime(q)) {
|
||||
return AnswerCardStatus.planTimeNotEnough;
|
||||
}
|
||||
if (_extended &&
|
||||
q['isUploadNotes'] == true &&
|
||||
!_hasUploadedNotes(q['notes'])) {
|
||||
return AnswerCardStatus.pendingNotes;
|
||||
}
|
||||
return AnswerCardStatus.answered;
|
||||
}
|
||||
|
||||
// result 模式
|
||||
if (!answered) return AnswerCardStatus.unanswered;
|
||||
|
||||
final rawStatus = q['answerStatus'];
|
||||
if (rawStatus != null) {
|
||||
final status = _num(rawStatus, -1);
|
||||
if (status == 10) return AnswerCardStatus.correct;
|
||||
if (status == 11 || status == 12 || status == 1) {
|
||||
return AnswerCardStatus.wrong;
|
||||
}
|
||||
if (status == 0) return AnswerCardStatus.unanswered;
|
||||
}
|
||||
|
||||
final myAnswer = _normalizeAnswer(q['userAnswer']);
|
||||
final correctAnswer =
|
||||
_normalizeAnswer(q['correctAnswer'] ?? q['answer']);
|
||||
return myAnswer == correctAnswer
|
||||
? AnswerCardStatus.correct
|
||||
: AnswerCardStatus.wrong;
|
||||
}
|
||||
|
||||
/// 拍平 + 按相邻题型分组
|
||||
List<Map<String, dynamic>> _groups() {
|
||||
final flat = buildExamAnswerCardQuestions(
|
||||
questions,
|
||||
isExamAnswerCard: isExamAnswerCard,
|
||||
)..sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
|
||||
|
||||
final result = <Map<String, dynamic>>[];
|
||||
Map<String, dynamic>? current;
|
||||
for (final q in flat) {
|
||||
final typeName = '${q['questionTypeName'] ?? '题目'}';
|
||||
if (current == null || current['name'] != typeName) {
|
||||
current = {
|
||||
'name': typeName,
|
||||
'questions': <Map<String, dynamic>>[],
|
||||
};
|
||||
result.add(current);
|
||||
}
|
||||
(current['questions'] as List).add({
|
||||
...q,
|
||||
'status': _questionStatus(q),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------- 渲染 ----------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!show) return const SizedBox.shrink();
|
||||
final isPad = isPadDevice(context);
|
||||
final screenH = MediaQuery.sizeOf(context).height;
|
||||
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onCancel,
|
||||
child: const ColoredBox(color: Color(0x99000000)),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
height: screenH * 0.8,
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
isPad
|
||||
? '/StaticResource/2026-06-02/answer-sheet-bg-Pad1780382627852.png'
|
||||
: '/StaticResource/2026-04-08/answer-sheet-bg.png',
|
||||
),
|
||||
),
|
||||
fit: isPad ? BoxFit.fill : BoxFit.fill,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(isPad),
|
||||
Expanded(child: _buildBody()),
|
||||
if (statusMode == 'answer') _buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// pad 右上关闭热区(50% 宽 × 400rpx)
|
||||
if (isPad)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: screenH * 0.04,
|
||||
width: MediaQuery.sizeOf(context).width * 0.5,
|
||||
height: 200.h,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onCancel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isPad) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: isPad ? 180.h : 95.h), // 190/360rpx
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题 "答题卡",右下角 title-bacs.png 装饰背景
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(right: 30.w),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/title-bacs.png'),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
fit: BoxFit.fitHeight,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'答题卡',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
if (statusMode == 'answer')
|
||||
_buildLegend(
|
||||
isPad,
|
||||
_extended
|
||||
? const [
|
||||
_LegendItem('未答', Color(0xFF94A5B8)),
|
||||
_LegendItem('已答', Color(0xFF3D7DF5)),
|
||||
_LegendItem('时长不足', Color(0xFFFF9F1A)),
|
||||
_LegendItem('待补笔记', Color(0xFF8B5CF6)),
|
||||
]
|
||||
: const [
|
||||
_LegendItem('未答', Color(0xFF94A5B8)),
|
||||
_LegendItem('已答', Color(0xFF3D7DF5)),
|
||||
],
|
||||
extended: _extended,
|
||||
)
|
||||
else
|
||||
_buildLegend(
|
||||
isPad,
|
||||
const [
|
||||
_LegendItem('答对', Color(0xFF00CC96)),
|
||||
_LegendItem('答错', Color(0xFFFF4D4D)),
|
||||
_LegendItem('未答', Color(0xFF94A5B8)),
|
||||
],
|
||||
extended: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend(
|
||||
bool isPad,
|
||||
List<_LegendItem> items, {
|
||||
required bool extended,
|
||||
}) {
|
||||
return Container(
|
||||
width: extended ? 320.w : 150.w,
|
||||
padding: EdgeInsets.all(5.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x05FFFFFF), Color(0x33FFFFFF)],
|
||||
),
|
||||
border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
for (final item in items)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
decoration: BoxDecoration(
|
||||
color: item.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
item.label,
|
||||
style:
|
||||
TextStyle(fontSize: 12.sp, color: ExerciseColors.subtitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final groups = _groups();
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 20.h, bottom: 20.h),
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: groups.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 20.h),
|
||||
itemBuilder: (context, i) {
|
||||
final g = groups[i];
|
||||
final qs = (g['questions'] as List).cast<Map<String, dynamic>>();
|
||||
return Container(
|
||||
padding: EdgeInsets.all(14.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: Text(
|
||||
g['name'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10.w,
|
||||
runSpacing: 10.h,
|
||||
children: [
|
||||
for (final q in qs)
|
||||
GestureDetector(
|
||||
onTap: () => onSelect?.call(q),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 44.w, // 88rpx
|
||||
height: 36.h, // 72rpx
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _itemBg(q['status'] as int),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
'${q['sort']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _itemColor(q['status'] as int),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
padding: EdgeInsets.only(top: 12.h, bottom: 12.h),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ExerciseButton(
|
||||
text: '继续答题',
|
||||
style: ExerciseButtonStyle.outline,
|
||||
onTap: onCancel,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: ExerciseButton(
|
||||
text: '交卷',
|
||||
style: ExerciseButtonStyle.blueGradient,
|
||||
onTap: onSubmit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _itemBg(int status) {
|
||||
switch (status) {
|
||||
case AnswerCardStatus.answered:
|
||||
return const Color(0xFFF0F8FF);
|
||||
case AnswerCardStatus.correct:
|
||||
return const Color(0xFFE8FAF6);
|
||||
case AnswerCardStatus.wrong:
|
||||
return const Color(0xFFFFF0F0);
|
||||
case AnswerCardStatus.planTimeNotEnough:
|
||||
return const Color(0xFFFFF6E8);
|
||||
case AnswerCardStatus.pendingNotes:
|
||||
return const Color(0xFFF4EEFF);
|
||||
default:
|
||||
return const Color(0xFFF5F7FA);
|
||||
}
|
||||
}
|
||||
|
||||
Color _itemColor(int status) {
|
||||
switch (status) {
|
||||
case AnswerCardStatus.answered:
|
||||
return Color(0xFF3D7DF5);
|
||||
case AnswerCardStatus.correct:
|
||||
return ExerciseColors.correct;
|
||||
case AnswerCardStatus.wrong:
|
||||
return const Color(0xFFFF5252);
|
||||
case AnswerCardStatus.planTimeNotEnough:
|
||||
return const Color(0xFFFF9F1A);
|
||||
case AnswerCardStatus.pendingNotes:
|
||||
return const Color(0xFF8B5CF6);
|
||||
default:
|
||||
return const Color(0xFF94A5B8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendItem {
|
||||
const _LegendItem(this.label, this.color);
|
||||
final String label;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
int _num(dynamic v, [int def = 0]) {
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse('${v ?? ''}') ?? def;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/// ============================================================
|
||||
/// 【ObjectiveAnswerView】客观题答案对比卡片
|
||||
/// (1:1 迁移 Vue components/AnswerView.vue 的 type == 'objective' 分支;
|
||||
/// 本页只使用客观题形态)
|
||||
///
|
||||
/// 视觉:
|
||||
/// 218rpx 卡片:上行作答状态图标+文案(10 正确 / 11 错误 / 12 部分正确 /
|
||||
/// 0 未作答),下行白色对比块:"正确答案:X / 你的答案:Y"
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
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/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class ObjectiveAnswerView extends StatelessWidget {
|
||||
const ObjectiveAnswerView({
|
||||
super.key,
|
||||
required this.answerStatus,
|
||||
this.userAnswer = '',
|
||||
this.correctAnswer = '',
|
||||
});
|
||||
|
||||
/// 0 未作答 / 10 正确 / 11 错误 / 12 部分正确
|
||||
final dynamic answerStatus;
|
||||
final String userAnswer;
|
||||
final String correctAnswer;
|
||||
|
||||
int get _status => int.tryParse('$answerStatus') ?? 0;
|
||||
|
||||
bool get _isCorrect => _status == 10;
|
||||
|
||||
/// 客观题文字展示清洗(对标 formatOptionText),仅用于判空与纯文本展示
|
||||
String _formatOptionText(dynamic ans) {
|
||||
if (ans == null) return '';
|
||||
|
||||
String str;
|
||||
if (ans is List) {
|
||||
str = ans.join();
|
||||
} else if (ans is Map) {
|
||||
str = ans.toString();
|
||||
if (str == '{}' || str == '[]') return '';
|
||||
} else {
|
||||
str = '$ans';
|
||||
}
|
||||
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty || trimmed == '[]' || trimmed == '{}' ||
|
||||
trimmed == '""' || trimmed == "''") {
|
||||
return '';
|
||||
}
|
||||
|
||||
var result = trimmed.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
result = result.replaceAll(' ', ' ');
|
||||
result = result.replaceAll(RegExp(r'&[a-z]+;'), '');
|
||||
result = result.replaceAll(RegExp(r'\s+'), ' ');
|
||||
result = result.replaceAll(RegExp(r'^(\d+[\.\s、)\s]+|第\d+题)'), '');
|
||||
result = result.replaceAll(',', '');
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
/// 清洗后的答案 HTML(富文本渲染用)
|
||||
String _rendered(String html) => cleanRichText(html);
|
||||
|
||||
bool _hasFormula(String html) =>
|
||||
RegExp(r'math-formula|katex').hasMatch(html);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final correctText = _formatOptionText(correctAnswer);
|
||||
final userText = _formatOptionText(userAnswer);
|
||||
final correctDisplay = correctText.isEmpty ? '无' : correctText;
|
||||
final userDisplay = userText.isEmpty ? '未作答' : userText;
|
||||
|
||||
final statusText = _status == 10
|
||||
? '回答正确'
|
||||
: _status == 11
|
||||
? '回答错误'
|
||||
: _status == 12
|
||||
? '部分正确'
|
||||
: '未作答';
|
||||
|
||||
final accent = _isCorrect ? ExerciseColors.correct : ExerciseColors.error;
|
||||
|
||||
final correctHtml = _rendered(correctAnswer);
|
||||
final userHtml = _rendered(userAnswer);
|
||||
|
||||
return Container(
|
||||
height: 109.h, // 218rpx
|
||||
decoration: BoxDecoration(
|
||||
// CSS 外层:实色 padding-box + 180deg 渐变 border-box
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: _isCorrect
|
||||
? const [Color(0x8000CC96), Color(0x1A00CC96), Color(0x8000CC96)]
|
||||
: const [Color(0x80FF4C4C), Color(0x1AFF4C4C), Color(0x80FF4C4C)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(1.w),
|
||||
decoration: BoxDecoration(
|
||||
color: _isCorrect ? const Color(0xFFEBFFFA) : const Color(0xFFFFF0F0),
|
||||
borderRadius: BorderRadius.circular(11.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 第一行:状态提示
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
|
||||
child: Row(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
_isCorrect
|
||||
? '/StaticResource/2026-06-01/correct-icon1780284329572.png'
|
||||
: '/StaticResource/2026-06-01/error-icon1780284292405.png',
|
||||
),
|
||||
width: 16.w,
|
||||
height: 16.w,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 第二行:答案对比
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.fromLTRB(1.w, 0, 1.w, 1.w),
|
||||
padding: EdgeInsets.fromLTRB(14.w, 0, 26.w, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CompareItem(
|
||||
label: '正确答案:',
|
||||
child: _hasFormula(correctHtml)
|
||||
? RichTextContent(
|
||||
html: correctHtml,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF333333),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
correctDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF333333),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _CompareItem(
|
||||
alignEnd: true,
|
||||
label: '你的答案:',
|
||||
child: _hasFormula(userHtml)
|
||||
? RichTextContent(
|
||||
html: userHtml,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _isCorrect
|
||||
? ExerciseColors.correct
|
||||
: (userText.isEmpty
|
||||
? const Color(0xFF999999)
|
||||
: ExerciseColors.error),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
userDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _isCorrect
|
||||
? ExerciseColors.correct
|
||||
: (userText.isEmpty
|
||||
? const Color(0xFF999999)
|
||||
: ExerciseColors.error),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompareItem extends StatelessWidget {
|
||||
const _CompareItem({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.alignEnd = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool alignEnd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.subtitle),
|
||||
),
|
||||
),
|
||||
Flexible(child: child),
|
||||
],
|
||||
);
|
||||
return alignEnd
|
||||
? Align(alignment: Alignment.centerRight, child: row)
|
||||
: Align(alignment: Alignment.centerLeft, child: row);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
/// ============================================================
|
||||
/// 【ConquerPopup】攻克流程弹窗
|
||||
/// (1:1 迁移 Vue components/ConquerPopup/ConquerPopup.vue)
|
||||
///
|
||||
/// 四种类型:
|
||||
/// note 过程笔记提示
|
||||
/// time 最低作答时长提示(确认键在左、取消键在右)
|
||||
/// submit 确认交卷(取消键在左、确认键在右)
|
||||
/// grading 题目批改中(遮罩与按钮都不能关闭;唯一按钮 emit confirm)
|
||||
///
|
||||
/// 事件(与 Vue emit 对齐):
|
||||
/// onConfirm(action) 点确认按钮
|
||||
/// onCancel() 点遮罩/取消;若 confirmAction == 'showAnswerSheet'
|
||||
/// (isConfirm 单按钮或遮罩)会紧跟一次 onConfirm(action)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
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/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class ConquerPopup extends StatelessWidget {
|
||||
const ConquerPopup({
|
||||
super.key,
|
||||
required this.show,
|
||||
this.type = 'note',
|
||||
this.title = '',
|
||||
this.subtitle = '',
|
||||
this.confirmText = '确定',
|
||||
this.cancelText = '取消',
|
||||
this.isConfirm = false,
|
||||
this.confirmAction = '',
|
||||
this.onConfirm,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
|
||||
/// note / time / submit / grading
|
||||
final String type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String confirmText;
|
||||
final String cancelText;
|
||||
final bool isConfirm;
|
||||
final String confirmAction;
|
||||
final ValueChanged<String>? onConfirm;
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
static const Map<String, String> _bgMap = {
|
||||
'note': '/StaticResource/2026-04-08/conquer-note-bg.png',
|
||||
'time': '/StaticResource/2026-04-08/conquer-time-bg.png',
|
||||
'submit': '/StaticResource/2026-04-08/conquer-submit-bg.png',
|
||||
'grading': '/StaticResource/2026-04-08/conquer-grading-bg.png',
|
||||
};
|
||||
|
||||
/// grading 遮罩点击:直接拦截不关闭
|
||||
void _handleMaskCancel() {
|
||||
if (type == 'grading') return;
|
||||
_handleCancel();
|
||||
}
|
||||
|
||||
/// 普通取消:关闭 + cancel + showAnswerSheet 时补发 confirm
|
||||
void _handleCancel() {
|
||||
onCancel?.call();
|
||||
if (confirmAction == 'showAnswerSheet') {
|
||||
onConfirm?.call(confirmAction);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConfirm() {
|
||||
onConfirm?.call(confirmAction);
|
||||
}
|
||||
|
||||
/// 标题/副标题可能是 HTML(v-html)
|
||||
Widget _htmlOrText(
|
||||
String html, {
|
||||
required double fontSize,
|
||||
required Color color,
|
||||
FontWeight? fontWeight,
|
||||
double? height,
|
||||
}) {
|
||||
final style = TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: color,
|
||||
fontWeight: fontWeight,
|
||||
height: height,
|
||||
);
|
||||
if (html.contains(RegExp(r'<[a-zA-Z][^>]*>'))) {
|
||||
return RichTextContent(html: html, textStyle: style);
|
||||
}
|
||||
return Text(html, textAlign: TextAlign.center, style: style);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!show) return const SizedBox.shrink();
|
||||
final isPad = isPadDevice(context);
|
||||
final isGrading = type == 'grading';
|
||||
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// mask rgba(0,0,0,.6)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleMaskCancel,
|
||||
child: const ColoredBox(color: Color(0x99000000)),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: isPad ? 305.w : 278.w, // 556rpx / 610rpx
|
||||
constraints: BoxConstraints(minHeight: 352.5.h), // 705rpx
|
||||
padding: EdgeInsets.only(
|
||||
left: 27.5.w, // 55rpx
|
||||
right: 25.w, // 50rpx
|
||||
bottom: 30.h, // 60rpx
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(_bgMap[type] ?? _bgMap['note']!),
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: isPad ? 170.h : 150.h),
|
||||
child: _htmlOrText(
|
||||
title,
|
||||
fontSize: 18.sp, // 36rpx
|
||||
color: ExerciseColors.title,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 25.h / 18.sp,
|
||||
),
|
||||
),
|
||||
if (isGrading) ...[
|
||||
// 预计 3~5 分钟有结果
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 20.h),
|
||||
height: 44.h, // 88rpx
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F8FF),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ExerciseColors.subtitle,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '预计 '),
|
||||
TextSpan(
|
||||
text: '3~5',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.primary,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: ' 分钟有结果'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 15.h),
|
||||
child: ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 220.w, // 440rpx
|
||||
onTap: _handleConfirm,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (subtitle.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h, left: 10.w, right: 10.w),
|
||||
child: _htmlOrText(
|
||||
subtitle,
|
||||
fontSize: 14.sp, // 28rpx
|
||||
color: ExerciseColors.subtitle,
|
||||
height: 22.h / 14.sp,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 25.h),
|
||||
child: isConfirm
|
||||
? ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: double.infinity,
|
||||
onTap: _handleCancel,
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: type == 'time'
|
||||
? [
|
||||
// time:确认在左(白底)、取消在右(蓝底)
|
||||
ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.secondary,
|
||||
width: 105.w, // 210rpx
|
||||
onTap: _handleConfirm,
|
||||
),
|
||||
ExerciseButton(
|
||||
text: cancelText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 105.w,
|
||||
onTap: _handleCancel,
|
||||
),
|
||||
]
|
||||
: [
|
||||
// note / submit:取消在左、确认在右
|
||||
ExerciseButton(
|
||||
text: cancelText,
|
||||
style: ExerciseButtonStyle.secondary,
|
||||
width: 105.w,
|
||||
onTap: _handleCancel,
|
||||
),
|
||||
ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 105.w,
|
||||
onTap: _handleConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/// ============================================================
|
||||
/// 【ExerciseConfirmBox】通用确认弹窗
|
||||
/// (1:1 迁移 Vue classExercises/ExerciseConfirmBox.vue)
|
||||
///
|
||||
/// 交互点:
|
||||
/// - 点遮罩 / 取消:onCancel(父级负责把 show 置 false)
|
||||
/// - 点确定:onConfirm(不主动关闭,与 Vue 一致由业务决定)
|
||||
/// - showCancel=false 时只显示单满宽确定按钮
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
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/utils/img_url.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class ExerciseConfirmBox extends StatelessWidget {
|
||||
const ExerciseConfirmBox({
|
||||
super.key,
|
||||
required this.show,
|
||||
this.title = '提示',
|
||||
this.content = '',
|
||||
this.cancelText = '取消',
|
||||
this.confirmText = '确定',
|
||||
this.showCancel = true,
|
||||
this.onCancel,
|
||||
this.onConfirm,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
final String title;
|
||||
final String content;
|
||||
final String cancelText;
|
||||
final String confirmText;
|
||||
final bool showCancel;
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback? onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!show) return const SizedBox.shrink();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// mask rgba(0,0,0,.45),点击=cancel
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onCancel,
|
||||
child: const ColoredBox(color: Color(0x73000000)),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 315.w, // 630rpx, max calc(100vw - 80rpx)
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width - 40.w,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 30.h, horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build('/StaticResource/2026-03-31/header-bac.png'),
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
if (content.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 18.h),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 263.w),
|
||||
child: Text(
|
||||
content,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ExerciseColors.title,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 23.h),
|
||||
child: showCancel
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ExerciseButton(
|
||||
text: cancelText,
|
||||
style: ExerciseButtonStyle.secondary,
|
||||
width: 135.w,
|
||||
onTap: onCancel,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 135.w,
|
||||
onTap: onConfirm,
|
||||
),
|
||||
],
|
||||
)
|
||||
: ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: double.infinity,
|
||||
onTap: onConfirm,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/// ============================================================
|
||||
/// 课堂习题模块共享样式
|
||||
///
|
||||
/// 集中放置 classExercises 各 widget 复用的:
|
||||
/// 1. 颜色常量(对标 Vue common.scss 的 $default-color 系列)
|
||||
/// 2. isPadDevice 判定(shortestSide >= 600)
|
||||
/// 3. 渐变描边容器(对标 CSS padding-box/border-box 双层渐变)
|
||||
/// 4. 通用胶囊按钮(对标 default-button1/2/3/4/5/6)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// 与 Vue 端一一对应的色板
|
||||
class ExerciseColors {
|
||||
ExerciseColors._();
|
||||
|
||||
/// 主题蓝 $default-color
|
||||
static const Color primary = Color(0xFF3D7DF5);
|
||||
|
||||
/// $default-color-strong
|
||||
static const Color primaryStrong = Color(0xFF3373FF);
|
||||
|
||||
/// $default-color-mid
|
||||
static const Color primaryMid = Color(0xFF3C84FF);
|
||||
|
||||
/// $default-color-light
|
||||
static const Color primaryLight = Color(0xFF8AD1FD);
|
||||
|
||||
/// 标题深色
|
||||
static const Color title = Color(0xFF1D2B47);
|
||||
|
||||
/// 次级文字
|
||||
static const Color subtitle = Color(0xFF687FAD);
|
||||
|
||||
/// 正确绿
|
||||
static const Color correct = Color(0xFF00CC96);
|
||||
|
||||
/// 错误红
|
||||
static const Color error = Color(0xFFFF4D4D);
|
||||
|
||||
/// 页面/卡片浅底
|
||||
static const Color bg = Color(0xFFF5F8FA);
|
||||
|
||||
/// 选项默认底
|
||||
static const Color tileBg = Color(0xFFFAFBFD);
|
||||
|
||||
/// 浅蓝描边
|
||||
static const Color border = Color(0xFFE3ECFF);
|
||||
}
|
||||
|
||||
/// 对标 Vue utils/util.js isPadDevice
|
||||
bool isPadDevice(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).shortestSide >= 600;
|
||||
|
||||
/// 选项 / 状态卡片的视觉态
|
||||
enum ExerciseTileState {
|
||||
/// 默认:浅底 + 浅蓝描边
|
||||
plain,
|
||||
|
||||
/// 作答中选中:蓝渐变描边
|
||||
active,
|
||||
|
||||
/// 正确:绿渐变描边
|
||||
correct,
|
||||
|
||||
/// 错误:红渐变描边
|
||||
error,
|
||||
}
|
||||
|
||||
/// 渐变描边容器
|
||||
///
|
||||
/// 对标 CSS 写法:
|
||||
/// `background: linear-gradient(...) padding-box,
|
||||
/// linear-gradient(...) border-box; border: 2rpx solid transparent;`
|
||||
/// Flutter 用「外层渐变 + 1.w 内边距 + 内层实色」实现相同观感。
|
||||
class GradientBorderBox extends StatelessWidget {
|
||||
const GradientBorderBox({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.state = ExerciseTileState.plain,
|
||||
this.radius = 12,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.borderWidth,
|
||||
this.fillColor,
|
||||
this.borderGradient,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final ExerciseTileState state;
|
||||
final double radius;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
/// 描边宽度(默认 2rpx = 1.w)
|
||||
final double? borderWidth;
|
||||
|
||||
/// 自定义内层填充色(不传按 state 取)
|
||||
final Color? fillColor;
|
||||
|
||||
/// 自定义描边渐变(不传按 state 取)
|
||||
final Gradient? borderGradient;
|
||||
|
||||
Color get _fill {
|
||||
if (fillColor != null) return fillColor!;
|
||||
switch (state) {
|
||||
case ExerciseTileState.plain:
|
||||
return ExerciseColors.tileBg;
|
||||
case ExerciseTileState.active:
|
||||
return const Color(0xFFF0F8FF);
|
||||
case ExerciseTileState.correct:
|
||||
return const Color(0xFFEBFFFA);
|
||||
case ExerciseTileState.error:
|
||||
return const Color(0xFFFFF0F0);
|
||||
}
|
||||
}
|
||||
|
||||
Gradient? get _gradient {
|
||||
if (borderGradient != null) return borderGradient;
|
||||
switch (state) {
|
||||
case ExerciseTileState.plain:
|
||||
return null;
|
||||
case ExerciseTileState.active:
|
||||
// rgba(101,189,250,.1) → rgba(101,189,250,.5)
|
||||
return const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0x1A65BDFF), Color(0x8065BDFF)],
|
||||
);
|
||||
case ExerciseTileState.correct:
|
||||
return const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0x1A00CC96), Color(0x8000CC96)],
|
||||
);
|
||||
case ExerciseTileState.error:
|
||||
return const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0x1AFF4C4C), Color(0x80FF4C4C)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bw = borderWidth ?? 1.w;
|
||||
final br = BorderRadius.circular(radius.r);
|
||||
final outer = state == ExerciseTileState.plain
|
||||
? BoxDecoration(
|
||||
color: _fill,
|
||||
borderRadius: br,
|
||||
border: Border.all(color: ExerciseColors.border, width: bw),
|
||||
)
|
||||
: BoxDecoration(
|
||||
gradient: _gradient,
|
||||
borderRadius: br,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: margin,
|
||||
decoration: outer,
|
||||
child: Container(
|
||||
margin: state == ExerciseTileState.plain ? null : EdgeInsets.all(bw),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: state == ExerciseTileState.plain ? null : _fill,
|
||||
borderRadius: BorderRadius.circular(
|
||||
state == ExerciseTileState.plain ? radius.r : radius.r - bw,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形选项标号的背景(对标 .options-li-Head 激活态径向渐变)
|
||||
Gradient? headGradientOf(ExerciseTileState state) {
|
||||
switch (state) {
|
||||
case ExerciseTileState.plain:
|
||||
return null;
|
||||
case ExerciseTileState.active:
|
||||
// radial-gradient(110.77% 110% at 50.93% 90%, #74CAFF, transparent), #3D7DF5
|
||||
return const RadialGradient(
|
||||
center: Alignment(0.0186, 0.8),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF74CAFF), ExerciseColors.primary],
|
||||
);
|
||||
case ExerciseTileState.correct:
|
||||
return const RadialGradient(
|
||||
center: Alignment(0.0262, 0.7894),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF52E3A2), ExerciseColors.correct],
|
||||
);
|
||||
case ExerciseTileState.error:
|
||||
return const RadialGradient(
|
||||
center: Alignment(0.0262, 0.7894),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFFFF877C), Color(0xFFFF4C4C)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 胶囊按钮样式(对标 default-button1/2/3/4)
|
||||
enum ExerciseButtonStyle {
|
||||
/// default-button1:左→右蓝色渐变,交卷/提交
|
||||
blueGradient,
|
||||
|
||||
/// default-button3:下→上浅蓝渐变,确定/确认
|
||||
primary,
|
||||
|
||||
/// default-button2:白底蓝字带投影,弹窗次级按钮
|
||||
secondary,
|
||||
|
||||
/// default-button4:白底蓝字蓝描边,继续答题
|
||||
outline,
|
||||
}
|
||||
|
||||
/// 通用胶囊按钮(92rpx 高、100rpx 圆角)
|
||||
class ExerciseButton extends StatelessWidget {
|
||||
const ExerciseButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.style = ExerciseButtonStyle.primary,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fontSize,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final VoidCallback? onTap;
|
||||
final ExerciseButtonStyle style;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double? fontSize;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final h = height ?? 46.h;
|
||||
final fs = fontSize ?? 16.sp;
|
||||
|
||||
Gradient? gradient;
|
||||
Color? color;
|
||||
Color textColor;
|
||||
Border? border;
|
||||
List<BoxShadow> shadows = const [];
|
||||
|
||||
switch (style) {
|
||||
case ExerciseButtonStyle.blueGradient:
|
||||
// linear-gradient(90deg, #3373FF 0%, #3D7DF5 100%)
|
||||
gradient = const LinearGradient(
|
||||
colors: [ExerciseColors.primaryStrong, ExerciseColors.primary],
|
||||
);
|
||||
textColor = Colors.white;
|
||||
shadows = [
|
||||
BoxShadow(
|
||||
color: const Color(0x4D3D7DF5),
|
||||
offset: Offset(0, 7.5.h),
|
||||
blurRadius: 15.h,
|
||||
),
|
||||
];
|
||||
break;
|
||||
case ExerciseButtonStyle.primary:
|
||||
// linear-gradient(360deg, #8AD1FD -11.22%, #3C84FF 60.2%, #3D7DF5 100%)
|
||||
gradient = const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
ExerciseColors.primaryLight,
|
||||
ExerciseColors.primaryMid,
|
||||
ExerciseColors.primary,
|
||||
],
|
||||
stops: [0, 0.602, 1],
|
||||
);
|
||||
textColor = Colors.white;
|
||||
shadows = [
|
||||
BoxShadow(
|
||||
color: const Color(0x143D7DF5),
|
||||
offset: Offset(0, 7.5.h),
|
||||
blurRadius: 5.65.h,
|
||||
),
|
||||
];
|
||||
break;
|
||||
case ExerciseButtonStyle.secondary:
|
||||
color = const Color(0xFFFDFFFF);
|
||||
textColor = ExerciseColors.primary;
|
||||
shadows = [
|
||||
BoxShadow(
|
||||
color: const Color(0x143D7DF5),
|
||||
offset: Offset(0, 7.5.h),
|
||||
blurRadius: 5.65.h,
|
||||
),
|
||||
];
|
||||
break;
|
||||
case ExerciseButtonStyle.outline:
|
||||
color = Colors.white;
|
||||
textColor = ExerciseColors.primary;
|
||||
border = Border.all(color: ExerciseColors.primary, width: 1.w);
|
||||
break;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: enabled ? 1 : 0.5,
|
||||
child: GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: width,
|
||||
height: h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(50.r),
|
||||
border: border,
|
||||
boxShadow: shadows,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: fs,
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/// ============================================================
|
||||
/// 【FeedbackPopup】题目反馈弹层
|
||||
/// (1:1 迁移 Vue components/FeedbackPopup/FeedbackPopup.vue,
|
||||
/// BottomPopup → showModalBottomSheet)
|
||||
///
|
||||
/// 用法(与 Vue defineExpose 对齐):
|
||||
/// `final key = GlobalKey<FeedbackPopupState>();`
|
||||
/// `FeedbackPopup(key: key);`
|
||||
/// `key.currentState!.open(id, questionId);`
|
||||
///
|
||||
/// 校验顺序:反馈内容 → 反馈类型;成功后自动关闭并重置。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
|
||||
import 'package:new_learn/core/components/center_toast.dart';
|
||||
import 'package:new_learn/core/components/upload.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class _FeedbackOption {
|
||||
const _FeedbackOption(this.label, this.value);
|
||||
final String label;
|
||||
final int value;
|
||||
}
|
||||
|
||||
const List<_FeedbackOption> _kOptions = [
|
||||
_FeedbackOption('题干错误', 1),
|
||||
_FeedbackOption(' 解析看不懂', 2),
|
||||
_FeedbackOption('答案错误', 3),
|
||||
_FeedbackOption('选项错误', 4),
|
||||
_FeedbackOption('批阅错误', 5),
|
||||
_FeedbackOption('其他', 0),
|
||||
];
|
||||
|
||||
class FeedbackPopup extends StatefulWidget {
|
||||
const FeedbackPopup({super.key});
|
||||
|
||||
@override
|
||||
State<FeedbackPopup> createState() => FeedbackPopupState();
|
||||
}
|
||||
|
||||
class FeedbackPopupState extends State<FeedbackPopup> {
|
||||
/// 打开反馈弹层
|
||||
void open(dynamic id, dynamic questionId) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _FeedbackSheet(id: id, questionId: questionId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
class _FeedbackSheet extends StatefulWidget {
|
||||
const _FeedbackSheet({required this.id, required this.questionId});
|
||||
|
||||
final dynamic id;
|
||||
final dynamic questionId;
|
||||
|
||||
@override
|
||||
State<_FeedbackSheet> createState() => _FeedbackSheetState();
|
||||
}
|
||||
|
||||
class _FeedbackSheetState extends State<_FeedbackSheet> {
|
||||
final Set<int> _selected = {};
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
List<UploadItem> _imageList = const [];
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_contentController.text.isEmpty) {
|
||||
CenterToast.show(context, '请输入反馈内容');
|
||||
return;
|
||||
}
|
||||
if (_selected.isEmpty) {
|
||||
CenterToast.show(context, '请选择反馈类型');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
// 提交中遮罩(对标 uni.showLoading),压在底部弹层之上
|
||||
unawaited(showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.black38,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
));
|
||||
|
||||
final payload = {
|
||||
'id': widget.id,
|
||||
'questionId': widget.questionId,
|
||||
'feedbackTypes': _selected.toList(),
|
||||
'description': _contentController.text,
|
||||
'images': _imageList.map((e) => e.url).toList(),
|
||||
};
|
||||
|
||||
try {
|
||||
final res = await ClassExercisesApi.instance.feedback(payload);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // 关闭 loading
|
||||
if (res != null && res != false) {
|
||||
CenterToast.show(context, '反馈成功');
|
||||
Navigator.of(context).pop(); // 关闭反馈弹层
|
||||
} else {
|
||||
CenterToast.show(context, '反馈失败,请检查网络');
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // 关闭 loading
|
||||
CenterToast.show(context, '反馈失败,请检查网络');
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(10)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildOptionsGrid(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20.h),
|
||||
child: _buildTextarea(),
|
||||
),
|
||||
Upload(
|
||||
title: '上传图片',
|
||||
maxCount: 5,
|
||||
type: UploadType.feedback,
|
||||
initialImageList: _imageList,
|
||||
onChanged: (list) =>
|
||||
setState(() => _imageList = list),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 30.h),
|
||||
child: ExerciseButton(
|
||||
text: '提交反馈',
|
||||
style: ExerciseButtonStyle.blueGradient,
|
||||
width: double.infinity,
|
||||
enabled: !_submitting,
|
||||
onTap: _submit,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
height: 60.h, // 120rpx
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFE1EEFF), Colors.white],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SizedBox(width: 24.w),
|
||||
Text(
|
||||
'题目反馈',
|
||||
style: TextStyle(fontSize: 16.sp, color: ExerciseColors.title),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Icon(Icons.close, size: 22.w, color: ExerciseColors.title),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionsGrid() {
|
||||
final chipW = (1.sw - 14.w * 2 - 16.w * 2) / 3;
|
||||
return Wrap(
|
||||
spacing: 16.w, // column-gap 32rpx
|
||||
runSpacing: 10.h, // row-gap 20rpx
|
||||
children: [
|
||||
for (final opt in _kOptions)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
if (!_selected.remove(opt.value)) _selected.add(opt.value);
|
||||
}),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: chipW,
|
||||
padding: EdgeInsets.only(top: 11.w, bottom: 13.5.h),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _selected.contains(opt.value)
|
||||
? const Color(0xFF6195F6)
|
||||
: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
opt.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _selected.contains(opt.value)
|
||||
? Colors.white
|
||||
: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextarea() {
|
||||
return TextField(
|
||||
controller: _contentController,
|
||||
maxLines: 4,
|
||||
minLines: 4,
|
||||
maxLength: 500,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请描述您的反馈...',
|
||||
hintStyle: TextStyle(fontSize: 14.sp, color: const Color(0xFFB3C2E0)),
|
||||
counterStyle: TextStyle(fontSize: 12.sp, color: const Color(0xFFB3C2E0)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFAFBFD),
|
||||
contentPadding: EdgeInsets.all(12.w),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(color: ExerciseColors.border, width: 1.w),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide:
|
||||
BorderSide(color: ExerciseColors.primary, width: 1.w),
|
||||
),
|
||||
),
|
||||
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.title),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/// ============================================================
|
||||
/// 【ObjOptions】客观题选项列表(1:1 迁移 Vue components/objOptions)
|
||||
///
|
||||
/// 交互点(与 Vue 一致):
|
||||
/// - props: options / answerData{correctAnswer,myAnswer} / answer /
|
||||
/// mode('edit'|'view') / questionType
|
||||
/// - edit:点击选项 emit change({...item, userAnswer: hxdb HTML})
|
||||
/// duoxuanti 多选解析已有答案后 toggle,按字母排序
|
||||
/// panduanti 取 optionLabel 或选项纯文本
|
||||
/// - view:不响应点击;正确项绿、选错项红
|
||||
/// - panduanti 标号位渲染本地正确/错误图标
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
enum ObjOptionMode { edit, view }
|
||||
|
||||
/// 对标 Vue answerData: { correctAnswer, myAnswer }
|
||||
class ObjOptionAnswerData {
|
||||
const ObjOptionAnswerData({
|
||||
required this.correctAnswer,
|
||||
required this.myAnswer,
|
||||
});
|
||||
|
||||
final String correctAnswer;
|
||||
final String myAnswer;
|
||||
}
|
||||
|
||||
/// change 事件载荷:{...option, userAnswer: String}
|
||||
typedef ObjOptionChanged = void Function(Map<String, dynamic> payload);
|
||||
|
||||
class ObjOptions extends StatelessWidget {
|
||||
const ObjOptions({
|
||||
super.key,
|
||||
required this.options,
|
||||
this.answerData,
|
||||
this.answer = '',
|
||||
this.mode = ObjOptionMode.view,
|
||||
this.questionType = '',
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
/// [{optionLabel, options}]
|
||||
final List<Map<String, String>> options;
|
||||
|
||||
/// view 模式答案对比数据;edit 模式通常不传
|
||||
final ObjOptionAnswerData? answerData;
|
||||
|
||||
/// edit 模式当前 userAnswer
|
||||
final String answer;
|
||||
final ObjOptionMode mode;
|
||||
|
||||
/// data.questionTypes,如 danxuanti / duoxuanti / panduanti
|
||||
final String questionType;
|
||||
final ObjOptionChanged? onChanged;
|
||||
|
||||
bool get _isPanDuan => questionType == 'panduanti';
|
||||
|
||||
/// 与 correctAnswer 一致的富文本外壳,内容为纯文本
|
||||
String _buildHxdbAnswerHtml(String plain) {
|
||||
if (plain.isEmpty) return '';
|
||||
final text = plain.trim();
|
||||
if (text.isEmpty) return '';
|
||||
return '<div class="hxdb-answer" name="hxdbAnswer">'
|
||||
'<p data-pm-slice="1 1 []">'
|
||||
'<span style="font-family: 宋体;"></span>$text</p></div>';
|
||||
}
|
||||
|
||||
/// 多选:从已有答案解析出选项字母(支持 hxdb HTML 或 A,B,D)
|
||||
List<String> _parseMultiSelectedLabels(
|
||||
String answerStr,
|
||||
List<String> validLabels,
|
||||
) {
|
||||
final plain = stripAnswerPlain(answerStr);
|
||||
if (plain.isEmpty) return const [];
|
||||
final valid = validLabels.where((e) => e.isNotEmpty).toSet();
|
||||
if (plain.contains(',')) {
|
||||
return plain
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where(valid.contains)
|
||||
.toList();
|
||||
}
|
||||
return plain.split('').where(valid.contains).toList();
|
||||
}
|
||||
|
||||
void _onClick(Map<String, String> item) {
|
||||
if (mode != ObjOptionMode.edit) return;
|
||||
final changed = onChanged;
|
||||
if (changed == null) return;
|
||||
|
||||
final validLabels =
|
||||
options.map((o) => o['optionLabel'] ?? '').where((e) => e.isNotEmpty).toList();
|
||||
final label = item['optionLabel'] ?? '';
|
||||
|
||||
if (questionType == 'duoxuanti') {
|
||||
final myAnswer = answerData != null &&
|
||||
answerData!.myAnswer.trim().isNotEmpty
|
||||
? answerData!.myAnswer
|
||||
: answer;
|
||||
final set = _parseMultiSelectedLabels(myAnswer, validLabels).toSet();
|
||||
if (label.isNotEmpty && set.contains(label)) {
|
||||
set.remove(label);
|
||||
} else if (label.isNotEmpty) {
|
||||
set.add(label);
|
||||
}
|
||||
final sorted = (set.toList()..sort()).join();
|
||||
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(sorted)});
|
||||
return;
|
||||
}
|
||||
|
||||
final plain = _isPanDuan
|
||||
? (label.isNotEmpty ? label : stripAnswerPlain(item['options']))
|
||||
: label;
|
||||
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(plain)});
|
||||
}
|
||||
|
||||
/// 选项内容富文本(td 转行内容器并清理头部空白)
|
||||
String _formatOption(String html) {
|
||||
if (html.isEmpty) return '';
|
||||
var res = cleanRichText(html);
|
||||
res = res.replaceAll(
|
||||
RegExp(r'<td\b[^>]*>', caseSensitive: false),
|
||||
'<span>',
|
||||
);
|
||||
res = res.replaceAll(
|
||||
RegExp(r'</td>', caseSensitive: false),
|
||||
'</span>',
|
||||
);
|
||||
res = res.replaceAll(RegExp(r'^(?: |\s)+', caseSensitive: false), '');
|
||||
return res;
|
||||
}
|
||||
|
||||
bool _isTrueOption(Map<String, String> item) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
final opts = item['options'] ?? '';
|
||||
return label == '正确' || label == '✓' || label == 'A' || opts.contains('正确');
|
||||
}
|
||||
|
||||
ExerciseTileState _stateOf(Map<String, String> item) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
if (label.isEmpty) return ExerciseTileState.plain;
|
||||
|
||||
var correctStr = '';
|
||||
var myStr = '';
|
||||
if (answerData != null) {
|
||||
correctStr = answerData!.correctAnswer
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.trim();
|
||||
myStr = answerData!.myAnswer
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.trim();
|
||||
} else {
|
||||
correctStr = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim();
|
||||
if (mode == ObjOptionMode.edit) {
|
||||
// 兼容老代码:没传 answerData 时把 answer 当作选中的高亮
|
||||
myStr = correctStr;
|
||||
}
|
||||
}
|
||||
|
||||
var isItemMine = myStr.contains(label);
|
||||
var isItemCorrect = correctStr.contains(label);
|
||||
|
||||
if (_isPanDuan) {
|
||||
final opts = item['options'] ?? '';
|
||||
if (label == '正确' || opts.contains('正确')) {
|
||||
isItemCorrect = ['正确', '✓', 'T', 'true', '对'].any(correctStr.contains);
|
||||
isItemMine = ['正确', '✓', 'T', 'true', '对'].any(myStr.contains);
|
||||
} else if (label == '错误' || opts.contains('错误') || opts.contains('错')) {
|
||||
isItemCorrect = ['错误', '×', 'F', 'false', '错'].any(correctStr.contains);
|
||||
isItemMine = ['错误', '×', 'F', 'false', '错'].any(myStr.contains);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == ObjOptionMode.edit) {
|
||||
return isItemMine ? ExerciseTileState.active : ExerciseTileState.plain;
|
||||
}
|
||||
if (isItemCorrect) return ExerciseTileState.correct;
|
||||
if (isItemMine && !isItemCorrect) return ExerciseTileState.error;
|
||||
return ExerciseTileState.plain;
|
||||
}
|
||||
|
||||
String _panDuanIcon(Map<String, String> item, ExerciseTileState state) {
|
||||
final isActive = state != ExerciseTileState.plain;
|
||||
if (_isTrueOption(item)) {
|
||||
return isActive
|
||||
? 'assets/img/my/correct-active-icon.png'
|
||||
: 'assets/img/my/correct-inactive-icon.png';
|
||||
}
|
||||
return isActive
|
||||
? 'assets/img/my/wrong-active-icon.png'
|
||||
: 'assets/img/my/wrong-inactive-icon.png';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < options.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: i == options.length - 1 ? 0 : 14.h),
|
||||
child: _buildItem(options[i]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(Map<String, String> item) {
|
||||
final state = _stateOf(item);
|
||||
return GestureDetector(
|
||||
onTap: () => _onClick(item),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: GradientBorderBox(
|
||||
state: state,
|
||||
radius: 12,
|
||||
padding: EdgeInsets.all(12.w), // 24rpx
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildHead(item, state),
|
||||
SizedBox(width: 14.w), // 76 标号 + 28 间距 = 104rpx
|
||||
Expanded(
|
||||
child: RichTextContent(
|
||||
html: _formatOption(item['options'] ?? ''),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.5,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHead(Map<String, String> item, ExerciseTileState state) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
final active = state != ExerciseTileState.plain;
|
||||
return Container(
|
||||
width: 38.w, // 76rpx
|
||||
height: 38.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? null : Colors.white,
|
||||
gradient: headGradientOf(state),
|
||||
border: active
|
||||
? null
|
||||
: Border.all(color: ExerciseColors.border, width: 1.w),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isPanDuan
|
||||
? Image.asset(
|
||||
_panDuanIcon(item, state),
|
||||
width: 20.w, // 40rpx
|
||||
height: 20.w,
|
||||
fit: BoxFit.contain,
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp, // 36rpx
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? Colors.white : ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/// ============================================================
|
||||
/// 【PausePopup】暂停答题弹窗
|
||||
/// (1:1 迁移 Vue components/PausePopup/PausePopup.vue)
|
||||
///
|
||||
/// - 遮罩 rgba(0,0,0,.7),点击任意位置关闭(onClose)
|
||||
/// - 组件独立秒表:每次 show=true 从 0 开始计时,HH:MM:SS(不足 1 小时隐去小时)
|
||||
/// - 文案:共 {totalCount} 道题,还剩 {remainingCount} 道题待完成
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
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/utils/img_url.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class PausePopup extends StatefulWidget {
|
||||
const PausePopup({
|
||||
super.key,
|
||||
required this.show,
|
||||
this.totalCount = 20,
|
||||
this.remainingCount = 0,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
final int totalCount;
|
||||
final int remainingCount;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
State<PausePopup> createState() => _PausePopupState();
|
||||
}
|
||||
|
||||
class _PausePopupState extends State<PausePopup> {
|
||||
int _seconds = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncTimer(widget.show);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PausePopup oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.show != widget.show) {
|
||||
_syncTimer(widget.show);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopTimer();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTimer(bool show) {
|
||||
if (show) {
|
||||
_startTimer();
|
||||
} else {
|
||||
_stopTimer();
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_seconds = 0;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _seconds++);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
String _two(int v) => v < 10 ? '0$v' : '$v';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.show) return const SizedBox.shrink();
|
||||
final isPad = isPadDevice(context);
|
||||
|
||||
final hours = _seconds ~/ 3600;
|
||||
final showHours = hours > 0;
|
||||
final minutes = (_seconds % 3600) ~/ 60;
|
||||
final secs = _seconds % 60;
|
||||
|
||||
final unitW = isPad ? 49.w : 44.w; // 88/98rpx
|
||||
final unitH = isPad ? 45.h : 40.h; // 80/90rpx
|
||||
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onClose,
|
||||
child: ColoredBox(
|
||||
color: const Color(0xB3000000), // rgba(0,0,0,.7)
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: Offset(isPad ? -35.w : 0, 0), // margin-left: -70rpx
|
||||
child: Container(
|
||||
width: isPad ? 315.5.w : 250.w, // 500/631rpx
|
||||
height: isPad ? 299.5.h : 249.h, // 498/599rpx
|
||||
padding: EdgeInsets.only(top: isPad ? 175.h : 137.5.h),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
isPad
|
||||
? '/StaticResource/2026-06-03/conquer-pause-bg-Pad1780466965818.png'
|
||||
: '/StaticResource/2026-04-08/conquer-pause-bg.png',
|
||||
),
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (showHours) ...[
|
||||
_timeUnit(unitW, unitH, _two(hours)),
|
||||
_timeSep(),
|
||||
],
|
||||
_timeUnit(unitW, unitH, _two(minutes)),
|
||||
_timeSep(),
|
||||
_timeUnit(unitW, unitH, _two(secs)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 15.h), // margin-t-30
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp, // 26rpx
|
||||
color: ExerciseColors.subtitle,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '共'),
|
||||
TextSpan(
|
||||
text: '${widget.totalCount}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '道题,还剩'),
|
||||
TextSpan(
|
||||
text: '${widget.remainingCount}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '道题待完成'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 25.h), // margin-t-50
|
||||
Text(
|
||||
'点击任意位置继续',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp, // 28rpx
|
||||
color: const Color(0xFFB4C2E0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeUnit(double w, double h, String text) {
|
||||
return Container(
|
||||
width: w,
|
||||
height: h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F8FF),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x1A1578FF),
|
||||
offset: Offset(0, 2.h),
|
||||
blurRadius: 6.h,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp, // 40rpx
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeSep() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w), // margin-rl-10 + 视觉间距
|
||||
child: Text(
|
||||
':',
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/// ============================================================
|
||||
/// 【QuestionPlanCountDown】单题建议作答时长倒计时
|
||||
/// (1:1 迁移 Vue classExercises/QuestionPlanCountDown.vue,
|
||||
/// 底层由 uview up-count-down 换为 Flutter Timer.periodic)
|
||||
///
|
||||
/// 对外方法(`GlobalKey<QuestionPlanCountDownState>` 调用):
|
||||
/// start / pause / reset / getRemainTime(毫秒)
|
||||
/// autoStart:initState 后自动开始;到点只触发一次 onFinish(key)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class QuestionPlanCountDown extends StatefulWidget {
|
||||
const QuestionPlanCountDown({
|
||||
super.key,
|
||||
this.questionKey,
|
||||
required this.timeMs,
|
||||
this.format = 'mm:ss',
|
||||
this.onFinish,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final Object? questionKey;
|
||||
|
||||
/// 剩余总时长(毫秒)
|
||||
final int timeMs;
|
||||
|
||||
/// 'mm:ss'(本页另有 HH:mm:ss 的动态格式,按是否含 HH 切换)
|
||||
final String format;
|
||||
final ValueChanged<Object?>? onFinish;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
State<QuestionPlanCountDown> createState() => QuestionPlanCountDownState();
|
||||
}
|
||||
|
||||
class QuestionPlanCountDownState extends State<QuestionPlanCountDown> {
|
||||
Timer? _timer;
|
||||
late int _remainMs;
|
||||
bool _finished = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_remainMs = widget.timeMs;
|
||||
// autoStart
|
||||
start();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant QuestionPlanCountDown oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.timeMs != widget.timeMs) {
|
||||
reset();
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (_timer != null) return;
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||
if (!mounted) return;
|
||||
_remainMs -= 200;
|
||||
if (_remainMs <= 0) {
|
||||
_remainMs = 0;
|
||||
pause();
|
||||
if (!_finished) {
|
||||
_finished = true;
|
||||
widget.onFinish?.call(widget.questionKey);
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
/// 重置回初始时长并暂停(与 uview reset 行为一致,调用方需再 start)
|
||||
void reset() {
|
||||
pause();
|
||||
_remainMs = widget.timeMs;
|
||||
_finished = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// 剩余毫秒
|
||||
int getRemainTime() => _remainMs < 0 ? 0 : _remainMs;
|
||||
|
||||
String _two(int v) => v < 10 ? '0$v' : '$v';
|
||||
|
||||
String get _display {
|
||||
// 展示向上取整:刚开始显示完整秒
|
||||
final seconds = (_remainMs / 1000).ceil().clamp(0, 1 << 31);
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = seconds % 60;
|
||||
if (widget.format.contains('HH')) {
|
||||
return '${_two(h)}:${_two(m)}:${_two(s)}';
|
||||
}
|
||||
return '${_two(m)}:${_two(s)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 字体的 ascent/descent 度量让文字视觉偏下,整体上移 2px 校正
|
||||
return Transform.translate(
|
||||
offset: const Offset(0, -2),
|
||||
child: Text(
|
||||
_display,
|
||||
style: widget.textStyle ??
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user